From 973214a5e55fb6fe757030b3628fde07a32dca40 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Sun, 24 May 2026 13:34:48 +0200 Subject: [PATCH 01/12] Unify minimizer category and switchable-selector API surface (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Draft minimizer category consolidation ADR * Add plans for minimizer consolidation and emcee * Add agent instructions and plan review workflow * Update minimizer consolidation plan after reviews * Move PosteriorParameterSummary to core * Add Parameter.posterior attribute * Add InitializationMethodEnum for samplers * Add MinimizerCategoryBase * Add concrete minimizer category classes * Resolve CIF '?' to descriptor default on load * Wire minimizer selector on Analysis owner * Serialize minimizer category to _minimizer.* CIF tags * Persist parameter posterior via _fit_parameter columns * Use Analysis.minimizer_type in non-analysis modules * Overwrite results.h5 on new fit with user warning * Absorb deterministic_result fields into LSQ minimizers * Read posterior plots from results.h5 groups * Remove obsolete Bayesian and fitting categories * Update tutorials for analysis.minimizer API * Promote minimizer-category-consolidation ADR * Complete Phase 1 review gate * Limit random seed category field to Bayesian minimizers * Prune unsupported LSQ minimizer descriptors * Address minimizer consolidation review comments * Remove obsolete minimizer category tests * Migrate tests to analysis minimizer selectors * Migrate project-load tests to minimizer state * Assert Bayesian projection sidecar payloads * Rewrite sidecar tests for consolidated results * Migrate fit-state tests to consolidated categories * Add minimizer category unit tests * Update Phase 2 test migration status * Apply Phase 2 fix and check updates * Update Phase 2 unit-test status * Stabilize DREAM integration tests * Update Phase 2 script-test status * Plan Phase-2 follow-ups P2.6-P2.10 from Review 8 * Compare FitResultKindEnum member in _should_use_sidecar results_sidecar._should_use_sidecar used a hard-coded 'bayesian' literal. Use FitResultKindEnum.BAYESIAN.value to follow the project convention of comparing against enum members. Implements P2.6 / F2. Co-Authored-By: Claude Opus 4.7 (1M context) * Default LSQ result descriptors to None for clean round-trip * Add reviewer constraint: no tests * Format minimizer-swap warning as removed/added lines * Validate Bayesian minimizer matches result_kind on restore * Tidy review-8 follow-ups (lint, hoisted F5 guard) * Reformat review-8 markdown files with prettier * Mark P2.10 verification complete * Propose switchable-category-owned-selectors ADR * Exempt review files from formatting pass * Incorporate switchable-category ADR review responses * Accept switchable category selector plan * Record accepted selector plan reply * Add SwitchableCategoryBase behavior-only mixin * Support collection-level scalar descriptors * Add owner-side switchable hook scaffolding * Wire analysis.minimizer to category-owned selector * Wire experiment.peak to category-owned selector * Wire experiment.background to category-owned selector * Wire experiment.extinction to category-owned selector * Rename Calculation to Calculator and wire to mixin * Split Rendering into Chart and Table sibling categories * Promote fitting_mode to FittingMode category * Drop persisted optimizer_name/method_name; add metadata dict * Remove owner-level selector shims and obsolete CIF tags * Update tutorials for category-owned selectors * Promote switchable-category-owned-selectors ADR * Complete Phase 1 review gate * Finalize fit timer before projecting result categories * Migrate tests to category-owned selectors * Run Phase 2 static fixes * Mark Phase 2 unit tests complete * Mark Phase 2 integration tests complete * Mark Phase 2 script tests complete * Collapse owner shadow state onto category descriptors * Simplify help() tables: drop # column, hide ✗, drop title * Raise on invalid category.type assignment * Validate chart/table types up-front and require parent on from_cif * Dispatch experiment filter context per switchable category * Narrow ed-24 archive normaliser to ID 35 with deletion roadmap * Delete completed consolidation and selector plan files * Delete obsolete switchable-selector ADR review/reply files * Apply formatting * Update tests to expect raise on invalid category.type * Adjust help-table tests for simplified row structure * Update pixi dependencies --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/copilot-instructions.md | 29 + AGENTS.md | 5 + CLAUDE.md | 1 + .../adrs/accepted/analysis-cif-fit-state.md | 138 +- .../adrs/accepted/category-owner-sections.md | 12 +- docs/dev/adrs/accepted/display-ux.md | 17 +- docs/dev/adrs/accepted/fit-mode-categories.md | 178 +- .../minimizer-category-consolidation.md | 469 ++++ docs/dev/adrs/accepted/runtime-fit-results.md | 11 +- docs/dev/adrs/accepted/selector-families.md | 33 +- .../adrs/accepted/switchable-category-api.md | 36 +- .../switchable-category-owned-selectors.md | 1041 +++++++++ docs/dev/adrs/index.md | 64 +- .../parameter-posterior-summary.md | 10 +- .../python-cif-category-correspondence.md | 210 +- docs/dev/issues/closed.md | 20 + docs/dev/issues/open.md | 143 +- docs/dev/package-structure/full.md | 114 +- docs/dev/package-structure/short.md | 60 +- docs/dev/plans/emcee-minimizer.md | 285 +++ docs/docs/tutorials/ed-11.ipynb | 5 +- docs/docs/tutorials/ed-11.py | 5 +- docs/docs/tutorials/ed-12.ipynb | 10 +- docs/docs/tutorials/ed-12.py | 10 +- docs/docs/tutorials/ed-13.ipynb | 6 +- docs/docs/tutorials/ed-13.py | 6 +- docs/docs/tutorials/ed-15.ipynb | 6 +- docs/docs/tutorials/ed-15.py | 6 +- docs/docs/tutorials/ed-16.ipynb | 6 +- docs/docs/tutorials/ed-16.py | 6 +- docs/docs/tutorials/ed-17.ipynb | 4 +- docs/docs/tutorials/ed-17.py | 4 +- docs/docs/tutorials/ed-2.ipynb | 8 +- docs/docs/tutorials/ed-2.py | 8 +- docs/docs/tutorials/ed-20.ipynb | 14 +- docs/docs/tutorials/ed-20.py | 14 +- docs/docs/tutorials/ed-21.ipynb | 12 +- docs/docs/tutorials/ed-21.py | 12 +- docs/docs/tutorials/ed-22.ipynb | 10 +- docs/docs/tutorials/ed-22.py | 10 +- docs/docs/tutorials/ed-24.ipynb | 51 +- docs/docs/tutorials/ed-24.py | 44 + docs/docs/tutorials/ed-3.ipynb | 24 +- docs/docs/tutorials/ed-3.py | 24 +- docs/docs/tutorials/ed-4.ipynb | 8 +- docs/docs/tutorials/ed-4.py | 8 +- docs/docs/tutorials/ed-5.ipynb | 6 +- docs/docs/tutorials/ed-5.py | 6 +- docs/docs/tutorials/ed-6.ipynb | 4 +- docs/docs/tutorials/ed-6.py | 4 +- docs/docs/tutorials/ed-7.ipynb | 12 +- docs/docs/tutorials/ed-7.py | 12 +- docs/docs/tutorials/ed-8.ipynb | 12 +- docs/docs/tutorials/ed-8.py | 12 +- docs/docs/tutorials/ed-9.ipynb | 2 +- docs/docs/tutorials/ed-9.py | 2 +- pixi.lock | 1976 ++++++++--------- src/easydiffraction/__main__.py | 5 +- src/easydiffraction/analysis/__init__.py | 52 +- src/easydiffraction/analysis/analysis.py | 799 ++++--- .../analysis/categories/__init__.py | 39 +- .../bayesian_convergence/__init__.py | 7 - .../bayesian_convergence/default.py | 121 - .../bayesian_distribution_caches/__init__.py | 12 - .../bayesian_distribution_caches/default.py | 151 -- .../bayesian_pair_caches/__init__.py | 8 - .../bayesian_pair_caches/default.py | 267 --- .../bayesian_parameter_posteriors/__init__.py | 12 - .../bayesian_parameter_posteriors/default.py | 242 -- .../bayesian_parameter_posteriors/factory.py | 17 - .../bayesian_predictive_datasets/__init__.py | 12 - .../bayesian_predictive_datasets/default.py | 260 --- .../bayesian_predictive_datasets/factory.py | 17 - .../categories/bayesian_result/__init__.py | 5 - .../categories/bayesian_result/default.py | 215 -- .../categories/bayesian_sampler/__init__.py | 5 - .../categories/bayesian_sampler/default.py | 133 -- .../deterministic_result/__init__.py | 7 - .../deterministic_result/default.py | 187 -- .../deterministic_result/factory.py | 17 - .../categories/fit_parameters/default.py | 198 ++ .../analysis/categories/fitting/__init__.py | 5 - .../analysis/categories/fitting/default.py | 125 -- .../analysis/categories/fitting/factory.py | 17 - .../categories/fitting_mode/__init__.py | 8 + .../categories/fitting_mode/default.py | 52 + .../factory.py | 6 +- .../analysis/categories/minimizer/__init__.py | 18 + .../analysis/categories/minimizer/base.py | 93 + .../categories/minimizer/bayesian_base.py | 404 ++++ .../analysis/categories/minimizer/bumps.py | 27 + .../categories/minimizer/bumps_amoeba.py | 27 + .../analysis/categories/minimizer/bumps_de.py | 27 + .../categories/minimizer/bumps_dream.py | 52 + .../analysis/categories/minimizer/bumps_lm.py | 27 + .../analysis/categories/minimizer/dfols.py | 27 + .../factory.py | 9 +- .../analysis/categories/minimizer/lmfit.py | 27 + .../minimizer/lmfit_least_squares.py | 27 + .../categories/minimizer/lmfit_leastsq.py | 27 + .../analysis/categories/minimizer/lsq_base.py | 279 +++ .../analysis/fit_helpers/__init__.py | 2 +- .../analysis/fit_helpers/bayesian.py | 54 +- src/easydiffraction/analysis/fitting.py | 5 + .../analysis/minimizers/base.py | 21 +- .../analysis/minimizers/enums.py | 9 + src/easydiffraction/analysis/sequential.py | 6 +- src/easydiffraction/core/category.py | 31 +- src/easydiffraction/core/collection.py | 8 +- src/easydiffraction/core/guard.py | 19 +- src/easydiffraction/core/posterior.py | 45 + src/easydiffraction/core/switchable.py | 104 + src/easydiffraction/core/validation.py | 13 +- src/easydiffraction/core/variable.py | 14 +- .../experiment/categories/background/base.py | 46 +- .../categories/calculation/__init__.py | 8 - .../categories/calculation/factory.py | 17 - .../categories/calculator/__init__.py | 10 + .../{calculation => calculator}/default.py | 73 +- .../categories/calculator}/factory.py | 6 +- .../experiment/categories/data/bragg_pd.py | 2 +- .../experiment/categories/data/total_pd.py | 2 +- .../experiment/categories/extinction/base.py | 54 + .../categories/extinction/becker_coppens.py | 6 +- .../experiment/categories/peak/base.py | 83 +- .../experiment/categories/refln/bragg_sc.py | 2 +- .../datablocks/experiment/item/base.py | 407 ++-- .../datablocks/experiment/item/bragg_pd.py | 79 +- .../datablocks/experiment/item/factory.py | 2 - src/easydiffraction/display/plotting.py | 10 +- src/easydiffraction/io/cif/parse.py | 2 +- src/easydiffraction/io/cif/serialize.py | 163 +- src/easydiffraction/io/results_sidecar.py | 472 +--- .../project/categories/chart/__init__.py | 8 + .../project/categories/chart/default.py | 100 + .../categories/chart}/factory.py | 6 +- .../project/categories/rendering/__init__.py | 8 - .../project/categories/rendering/default.py | 165 -- .../project/categories/rendering/factory.py | 17 - .../project/categories/table/__init__.py | 8 + .../project/categories/table/default.py | 97 + .../categories/table}/factory.py | 6 +- src/easydiffraction/project/display.py | 94 +- src/easydiffraction/project/project.py | 42 +- src/easydiffraction/project/project_config.py | 20 +- src/easydiffraction/summary/summary.py | 8 +- src/easydiffraction/utils/utils.py | 24 +- .../functional/test_switchable_categories.py | 10 +- tests/integration/fitting/conftest.py | 2 +- .../test_analysis_and_fit_category_support.py | 95 +- .../fitting/test_analysis_display.py | 8 +- .../fitting/test_bayesian_dream.py | 17 +- .../fitting/test_exploration_help.py | 15 +- tests/integration/fitting/test_multi.py | 10 +- .../test_pair-distribution-function.py | 2 +- ..._powder-diffraction_constant-wavelength.py | 6 +- .../test_powder-diffraction_joint-fit.py | 12 +- .../test_powder-diffraction_time-of-flight.py | 8 +- .../integration/fitting/test_project_load.py | 7 +- tests/integration/fitting/test_sequential.py | 2 +- .../fitting/test_switch-calculator.py | 4 +- .../categories/fitting/test_default.py | 37 - .../categories/fitting/test_factory.py | 24 - .../categories/fitting_mode/test_default.py | 63 + .../categories/fitting_mode/test_factory.py | 23 + .../categories/minimizer/test_base.py | 19 + .../minimizer/test_bayesian_base.py | 65 + .../categories/minimizer/test_bumps.py | 16 + .../categories/minimizer/test_bumps_amoeba.py | 18 + .../categories/minimizer/test_bumps_de.py | 16 + .../categories/minimizer/test_bumps_dream.py | 18 + .../categories/minimizer/test_bumps_lm.py | 16 + .../categories/minimizer/test_dfols.py | 16 + .../categories/minimizer/test_factory.py | 32 + .../categories/minimizer/test_lmfit.py | 16 + .../minimizer/test_lmfit_least_squares.py | 18 + .../minimizer/test_lmfit_leastsq.py | 18 + .../categories/minimizer/test_lsq_base.py | 52 + .../categories/test_bayesian_convergence.py | 17 - .../test_bayesian_distribution_caches.py | 17 - .../categories/test_bayesian_pair_caches.py | 15 - .../test_bayesian_parameter_posteriors.py | 17 - .../test_bayesian_predictive_datasets.py | 17 - .../categories/test_bayesian_result.py | 13 - .../categories/test_bayesian_sampler.py | 13 - .../categories/test_deterministic_result.py | 17 - .../analysis/categories/test_fit.py | 85 - .../analysis/categories/test_fit_state.py | 145 +- .../easydiffraction/analysis/test_analysis.py | 115 +- .../analysis/test_analysis_coverage.py | 31 +- .../easydiffraction/analysis/test_fitting.py | 6 + .../easydiffraction/core/test_category.py | 2 - .../easydiffraction/core/test_datablock.py | 1 - tests/unit/easydiffraction/core/test_guard.py | 2 - .../easydiffraction/core/test_posterior.py | 26 + .../easydiffraction/core/test_switchable.py | 130 ++ .../core/test_variable_posterior.py | 37 + .../categories/calculation/test_default.py | 90 - .../categories/calculation/test_factory.py | 27 - .../categories/calculator/test_default.py | 101 + .../categories/calculator/test_factory.py | 27 + .../experiment/categories/peak/test_base.py | 8 +- .../datablocks/experiment/item/test_base.py | 28 +- .../experiment/item/test_base_coverage.py | 29 +- .../experiment/item/test_bragg_pd.py | 21 +- .../experiment/item/test_bragg_sc_coverage.py | 17 +- .../experiment/item/test_factory.py | 6 +- .../test_serialize_category_owner_baseline.py | 8 +- .../io/cif/test_serialize_more.py | 21 +- .../io/test_results_sidecar.py | 173 +- .../project/categories/chart/test_default.py | 104 + .../project/categories/chart/test_factory.py | 23 + .../categories/rendering/test_default.py | 69 - .../categories/rendering/test_factory.py | 23 - .../project/categories/table/test_default.py | 70 + .../project/categories/table/test_factory.py | 23 + .../easydiffraction/project/test_display.py | 26 +- .../easydiffraction/project/test_project.py | 9 +- .../project/test_project_config.py | 48 +- .../project/test_project_load.py | 95 +- .../easydiffraction/summary/test_summary.py | 7 +- .../summary/test_summary_details.py | 16 +- tests/unit/easydiffraction/test___main__.py | 5 +- .../unit/easydiffraction/utils/test_utils.py | 1 - 224 files changed, 8086 insertions(+), 6055 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/dev/adrs/accepted/minimizer-category-consolidation.md create mode 100644 docs/dev/adrs/accepted/switchable-category-owned-selectors.md create mode 100644 docs/dev/plans/emcee-minimizer.md delete mode 100644 src/easydiffraction/analysis/categories/bayesian_convergence/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_convergence/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_distribution_caches/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_distribution_caches/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_pair_caches/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_pair_caches/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/factory.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_predictive_datasets/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_predictive_datasets/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_predictive_datasets/factory.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_result/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_result/default.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_sampler/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/bayesian_sampler/default.py delete mode 100644 src/easydiffraction/analysis/categories/deterministic_result/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/deterministic_result/default.py delete mode 100644 src/easydiffraction/analysis/categories/deterministic_result/factory.py delete mode 100644 src/easydiffraction/analysis/categories/fitting/__init__.py delete mode 100644 src/easydiffraction/analysis/categories/fitting/default.py delete mode 100644 src/easydiffraction/analysis/categories/fitting/factory.py create mode 100644 src/easydiffraction/analysis/categories/fitting_mode/__init__.py create mode 100644 src/easydiffraction/analysis/categories/fitting_mode/default.py rename src/easydiffraction/analysis/categories/{bayesian_pair_caches => fitting_mode}/factory.py (68%) create mode 100644 src/easydiffraction/analysis/categories/minimizer/__init__.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/base.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bayesian_base.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bumps.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bumps_amoeba.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bumps_de.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bumps_dream.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/bumps_lm.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/dfols.py rename src/easydiffraction/analysis/categories/{bayesian_distribution_caches => minimizer}/factory.py (51%) create mode 100644 src/easydiffraction/analysis/categories/minimizer/lmfit.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/lmfit_least_squares.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/lmfit_leastsq.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/lsq_base.py create mode 100644 src/easydiffraction/core/posterior.py create mode 100644 src/easydiffraction/core/switchable.py delete mode 100644 src/easydiffraction/datablocks/experiment/categories/calculation/__init__.py delete mode 100644 src/easydiffraction/datablocks/experiment/categories/calculation/factory.py create mode 100644 src/easydiffraction/datablocks/experiment/categories/calculator/__init__.py rename src/easydiffraction/datablocks/experiment/categories/{calculation => calculator}/default.py (52%) rename src/easydiffraction/{analysis/categories/bayesian_convergence => datablocks/experiment/categories/calculator}/factory.py (67%) create mode 100644 src/easydiffraction/datablocks/experiment/categories/extinction/base.py create mode 100644 src/easydiffraction/project/categories/chart/__init__.py create mode 100644 src/easydiffraction/project/categories/chart/default.py rename src/easydiffraction/{analysis/categories/bayesian_result => project/categories/chart}/factory.py (71%) delete mode 100644 src/easydiffraction/project/categories/rendering/__init__.py delete mode 100644 src/easydiffraction/project/categories/rendering/default.py delete mode 100644 src/easydiffraction/project/categories/rendering/factory.py create mode 100644 src/easydiffraction/project/categories/table/__init__.py create mode 100644 src/easydiffraction/project/categories/table/default.py rename src/easydiffraction/{analysis/categories/bayesian_sampler => project/categories/table}/factory.py (71%) delete mode 100644 tests/unit/easydiffraction/analysis/categories/fitting/test_default.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/fitting/test_factory.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fitting_mode/test_default.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fitting_mode/test_factory.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_base.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_amoeba.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_de.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_dream.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_lm.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_dfols.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_factory.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_least_squares.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_leastsq.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_convergence.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_distribution_caches.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_pair_caches.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_parameter_posteriors.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_predictive_datasets.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_result.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_bayesian_sampler.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_deterministic_result.py delete mode 100644 tests/unit/easydiffraction/analysis/categories/test_fit.py create mode 100644 tests/unit/easydiffraction/core/test_posterior.py create mode 100644 tests/unit/easydiffraction/core/test_switchable.py create mode 100644 tests/unit/easydiffraction/core/test_variable_posterior.py delete mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_default.py delete mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_factory.py create mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_default.py create mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/chart/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/chart/test_factory.py delete mode 100644 tests/unit/easydiffraction/project/categories/rendering/test_default.py delete mode 100644 tests/unit/easydiffraction/project/categories/rendering/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/table/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/table/test_factory.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 46a066d2b..1bf989d5c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -122,6 +122,25 @@ tests, tutorials, docs). Use `git grep -n` because all contributors have Git; do not assume `rg` is installed. If `git grep` is unavailable, fall back to `find ... -type f` plus `grep -n`. +- When asked to review a plan, save the review next to that plan using + `_review-N.md`, where `N` is one greater than the highest + existing review number for that plan. For example, + `docs/dev/plans/background-refactor.md` is reviewed in + `docs/dev/plans/background-refactor_review-1.md`, then + `docs/dev/plans/background-refactor_review-2.md`. A reviewer must not + run tests, `pixi run fix`, `pixi run check`, or any other build or + verification command; reviews are static reads of code, plan, and + documentation only. Note in the review which checks were skipped so + the next implementer knows the gap. +- Writing a review or a reply to a review does **not** require running + any formatter (`prettier`, `pixi run fix`, `ruff format`, …) or any + lint/check/test command on the review/reply file itself or any + surrounding documentation. Review and reply files are markdown-only, + written by hand, and committed as-is. Formatting passes happen later, + during implementation Phase 2 verification — not in the review cycle. + This rule applies to both `_review-N.md` and `_reply-N.md` files + regardless of where they live (`docs/dev/plans/`, `docs/dev/adrs/…/`, + etc.). - Each change is atomic and single-commit-sized: make one change, suggest the commit message, then stop and wait for confirmation. - When in doubt, ask. @@ -222,3 +241,13 @@ When asked to create a plan: understand the benefit. Update it during implementation if extra approved changes become important enough to mention in the PR title or description. +- When replying to a plan review, save the reply alongside the review. + Reviews live at `docs/dev/plans/_review-.md`; the + matching reply goes to `docs/dev/plans/_reply-.md` + (same slug, same number, swap `review` → `reply`). One reply file per + review file; do not bundle replies to multiple reviews into one + document. Structure the reply with one section per finding, each + containing a verdict (agree / disagree / partial), the action taken in + the plan, and a pointer to the affected plan section. After updating + the plan, also update the reply if a numbered step shifts so that + cross-references stay accurate. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..60be90eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Agent Instructions + +Follow +[`.github/copilot-instructions.md`](.github/copilot-instructions.md) for +this repository. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b21d16db8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@.github/copilot-instructions.md diff --git a/docs/dev/adrs/accepted/analysis-cif-fit-state.md b/docs/dev/adrs/accepted/analysis-cif-fit-state.md index a38f0e7c7..9883a3b94 100644 --- a/docs/dev/adrs/accepted/analysis-cif-fit-state.md +++ b/docs/dev/adrs/accepted/analysis-cif-fit-state.md @@ -15,8 +15,8 @@ Analysis and fitting. ## Context `analysis/analysis.cif` already persists analysis configuration such as -`_fitting.minimizer_type`, `_fitting.mode_type`, aliases, constraints, -and active fit-mode settings. That configuration alone is not enough to +`_minimizer.type`, `_fitting_mode.type`, aliases, constraints, and +active fit-mode settings. That configuration alone is not enough to reopen a saved project and continue the same fit-result, plotting, and command-line workflow. @@ -26,9 +26,9 @@ Analysis-owned fit state needs to persist: - pre-fit scalar snapshots for recovery workflows - compact status metadata for the latest saved fit projection - deterministic correlation summaries -- Bayesian summary metadata and manifests for bulk array sidecars -- plot-ready Bayesian caches so restored posterior displays do not need - to recompute on first use +- minimizer-specific fit outputs on the active `_minimizer.*` category +- per-parameter posterior summaries on `_fit_parameter` +- large posterior arrays and plot caches in `analysis/results.h5` Committed model parameter values and uncertainties already persist in structure and experiment CIF files through the accepted free-flag CIF @@ -41,13 +41,14 @@ projection. This ADR defines that narrower saved projection. ## Decision -Persist analysis-owned fit state as explicit sibling categories in -`analysis/analysis.cif`, with large Bayesian arrays stored in +Persist analysis-owned fit state as explicit analysis categories in +`analysis/analysis.cif`, with large posterior arrays stored in `analysis/results.h5`. Do not add a dedicated `_fit_state` category or `_fit_state.schema_version`. Persisted fit state is detected from -`_fit_result` and the related fit-state categories. +`_fit_result`, `_fit_parameter`, `_fit_parameter_correlation`, and +fit-output fields on `_minimizer.*`. ### Common fit-state categories @@ -66,6 +67,15 @@ pre-fit scalar snapshots: - `fit_bounds_uncertainty_multiplier` - `start_value` - `start_uncertainty` +- `posterior_best_sample_value` +- `posterior_median` +- `posterior_uncertainty` +- `posterior_interval_68_low` +- `posterior_interval_68_high` +- `posterior_interval_95_low` +- `posterior_interval_95_high` +- `posterior_gelman_rubin` +- `posterior_effective_sample_size_bulk` `_fit_result` stores the latest saved fit header: @@ -80,15 +90,12 @@ pre-fit scalar snapshots: correlation summaries keyed by a persisted `id`. Only unique parameter pairs are stored. -### Deterministic fit projection +### Minimizer fit projection -Deterministic fits persist `_deterministic_result` in addition to the -common categories above. +The active `_minimizer.*` category stores both user-selected solver +inputs and fit-filled outputs. Deterministic minimizer classes store +compact fit output counts: -`_deterministic_result` stores compact optimizer metadata and counts: - -- `optimizer_name` -- `method_name` - `objective_name` - `objective_value` - `n_data_points` @@ -97,60 +104,56 @@ common categories above. - `degrees_of_freedom` - `covariance_available` - `correlation_available` +- `runtime_seconds` +- `iterations_performed` +- `exit_reason` Do not persist a `_deterministic_parameter_result` category. Final deterministic parameter values and uncertainties already persist in the model CIF files, and restored deterministic ordering comes from `_fit_parameter`. -### Bayesian fit projection - -Bayesian fits persist these additional categories: - -- `_bayesian_result` -- `_bayesian_sampler` -- `_bayesian_convergence` -- `_bayesian_parameter_posterior` -- `_bayesian_distribution_cache` -- `_bayesian_pair_cache` -- `_bayesian_predictive_dataset` - -`_bayesian_result` stores the saved Bayesian header and sidecar flags, -including `sidecar_file`, `has_posterior_samples`, -`has_distribution_cache`, `has_pair_cache`, and -`has_posterior_predictive`. - -`_bayesian_sampler` stores the resolved sampler settings used for the -run. `parallel` persists the resolved non-negative worker count as an -integer. - -`_bayesian_convergence` stores convergence metadata and posterior array -shape counts. - -`_bayesian_parameter_posterior` stores one summary row per sampled -parameter, including credible intervals, uncertainty, ESS, and R-hat. -Its row order defines the saved posterior parameter order. - -`_bayesian_distribution_cache`, `_bayesian_pair_cache`, and -`_bayesian_predictive_dataset` store manifest rows for plot-ready -posterior caches. Distribution and predictive caches are persisted for -any Bayesian fit with posterior samples, including single-parameter -fits. Pair caches and posterior correlation summaries are only persisted -when more than one parameter was sampled. - -`parameter.posterior` is not part of this accepted design. This ADR -persists analysis-level posterior summaries and caches only. Any future -parameter-level posterior API remains a separate decision. - -### Bayesian sidecar - -Persist large Bayesian arrays in `analysis/results.h5` using `h5py`. -This includes canonical posterior arrays and any saved distribution, -pair, and predictive cache arrays referenced by the CIF manifests. - -The persisted `sidecar_file` value is a local file name only. It must -resolve to a basename inside the project `analysis/` directory. Absolute -paths and traversal paths are rejected and fall back to `results.h5`. +Bayesian minimizer classes store sampler inputs and fit outputs under +`_minimizer.*`, including: + +- `sampling_steps` +- `burn_in_steps` +- `thinning_interval` +- `population_size` +- `parallel_workers` +- `initialization_method` +- `random_seed` +- `runtime_seconds` +- `point_estimate_name` +- `sampler_completed` +- `credible_interval_inner` +- `credible_interval_outer` +- `acceptance_rate_mean` +- `gelman_rubin_max` +- `effective_sample_size_min` +- `best_log_posterior` + +Bayesian per-parameter posterior summaries are stored on the +corresponding `_fit_parameter` rows. Their row order defines the saved +posterior parameter order. + +`FitResults.optimizer_name` and `FitResults.method_name` are restored +from the active minimizer category class instead of being persisted as +independent CIF fields. Each concrete minimizer category declares a +class-level `_engine_metadata: ClassVar[dict[str, str]]` containing +those two display values. This keeps the persisted projection to the +user-selected `_minimizer.type` and removes duplicated deterministic +metadata from `_minimizer.*`. + +### Posterior sidecar + +Persist large posterior arrays in `analysis/results.h5` using `h5py`. +This includes canonical posterior arrays and saved distribution, pair, +and predictive cache arrays. The HDF5 file is self-describing; no CIF +manifest rows or sidecar filename tags are persisted. + +The sidecar filename is fixed to `results.h5` inside the project +`analysis/` directory. If the sidecar is missing on load, summary rows in `analysis/analysis.cif` still restore fit tables and metadata. Features @@ -168,9 +171,9 @@ Load order is: 1. standard analysis configuration 2. common fit-state categories -3. deterministic or Bayesian fit-specific categories according to - `_fit_result.result_kind` -4. Bayesian sidecar arrays when a Bayesian sidecar is expected +3. `_minimizer.*` fit-output fields according to the active + `_minimizer.type` +4. posterior sidecar arrays when a Bayesian result is expected Persist backend runtime objects, optimizer instances, and raw driver payloads nowhere in this design. @@ -186,7 +189,8 @@ values remain in the model CIF files instead of being duplicated in a second deterministic per-parameter result loop. Bayesian persistence spans CIF metadata and an HDF5 sidecar, so save and -load must validate consistency between manifest rows and bulk datasets. +load must validate consistency between `_fit_parameter` rows and bulk +datasets. The accepted runtime fit-results ADR should now be read as runtime-only except where this narrower projection explicitly persists fit-state diff --git a/docs/dev/adrs/accepted/category-owner-sections.md b/docs/dev/adrs/accepted/category-owner-sections.md index 46f9b3db0..a5cf27b39 100644 --- a/docs/dev/adrs/accepted/category-owner-sections.md +++ b/docs/dev/adrs/accepted/category-owner-sections.md @@ -79,17 +79,19 @@ Project-level configuration follows the same pattern via a private Its current children are: - `ProjectInfo` -- `Rendering` +- `Chart` +- `Table` The public API stays flat and user-facing: - `project.info` -- `project.rendering` +- `project.chart` +- `project.table` Saved `project.cif` remains a section file without a `data_` header. It -serializes the `_project.*` metadata category and the `_rendering.*` -configuration category without pretending that the project config is a -real datablock. +serializes the `_project.*` metadata category plus the `_chart.*` and +`_table.*` configuration categories without pretending that the project +config is a real datablock. ### 4. CIF serialization is split by responsibility diff --git a/docs/dev/adrs/accepted/display-ux.md b/docs/dev/adrs/accepted/display-ux.md index 23a780812..0b33b708e 100644 --- a/docs/dev/adrs/accepted/display-ux.md +++ b/docs/dev/adrs/accepted/display-ux.md @@ -42,23 +42,22 @@ defaults. ## Decision Use `project.display` as the user-facing facade for display actions. -Move serialized renderer settings out of that facade and into a separate -project category named `project.rendering`. +Move serialized renderer settings out of that facade and into separate +project categories named `project.chart` and `project.table`. Renderer settings: ```python -project.rendering.chart_engine = 'plotly' -project.rendering.table_engine = 'pandas' -project.rendering.show_chart_engines() -project.rendering.show_table_engines() -project.rendering.show_config() +project.chart.type = 'plotly' +project.table.type = 'pandas' +project.chart.show_supported() +project.table.show_supported() ``` CIF names: -- `_rendering.chart_engine` -- `_rendering.table_engine` +- `_chart.type` +- `_table.type` No legacy loader is required for `_display.plotter_type` or `_display.tabler_type`. The project is in beta, so this cleanup may diff --git a/docs/dev/adrs/accepted/fit-mode-categories.md b/docs/dev/adrs/accepted/fit-mode-categories.md index 278d3752a..1ced6aa58 100644 --- a/docs/dev/adrs/accepted/fit-mode-categories.md +++ b/docs/dev/adrs/accepted/fit-mode-categories.md @@ -101,50 +101,65 @@ to keep legacy runtime aliases. ## Decision +This ADR is amended by +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md). +The active-sibling design remains, but the selector surface is now the +`FittingMode` category: + +```python +project.analysis.fitting_mode.type = 'sequential' +project.analysis.fitting_mode.show_supported() +project.analysis.fit() +``` + +The selector persists as `_fitting_mode.type`. The old +`analysis.fitting_mode_type`, `show_supported_fitting_mode_types()`, +`show_current_fitting_mode_type()`, and `_fitting.mode_type` surfaces +are superseded. + ### 1. Split fitting configuration from fit execution `Analysis.fit()` becomes the public operation that executes the current fit mode. -Common fitting configuration moves to a dedicated category: +Common fitting configuration lives directly on `Analysis`: ```python -project.analysis.fitting.minimizer_type = 'lmfit (leastsq)' +project.analysis.minimizer.type = 'lmfit (leastsq)' project.analysis.fit() ``` `project.analysis.fit` is no longer a category. It is an action method. -The common `fitting` category owns configuration shared by all fit +The owner-level analysis surface owns configuration shared by all fit modes. Initially this includes: - `minimizer_type` Additional settings that apply to all fit modes can be added here later. Verbosity remains a call-level or project-level concern and does not -need to be persisted in this category. +need a fitting category. -**Single source of truth.** `Analysis.fitting_mode_type` is the only +**Single source of truth.** `Analysis.fitting_mode.type` is the only writable surface for the active mode, and the only place the mode is -stored at runtime. The CIF field `_fitting.mode_type` (§8) is -synthesized directly from `analysis.fitting_mode_type` at serialization -time and applied back to the selector on load. There is no mirror -descriptor on the `fitting` category. This keeps the runtime model free -of duplicated state. +stored at runtime. The CIF field `_fitting_mode.type` (§8) is emitted +from the `FittingMode` category and applied back to that category on +load. There is no mirror descriptor on a `fitting` category. This keeps +the runtime model free of duplicated state. -### 2. Add an owner-level fitting-mode selector +### 2. Add a `FittingMode` selector category -`Analysis` owns the fitting-mode selector, following the existing -switchable-category style used by experiment categories. +`Analysis` owns a `fitting_mode` category whose `.type` selector follows +the common category-owned switchable selector style. -The selector name must start with the public category name. This mirrors -`peak_profile_type` and `show_peak_profile_types()`: the category is -`peak`, and the selected aspect is the peak profile. For fitting, the -category is `fitting`, and the selected aspect is the fitting mode. +The category name is the public noun. The selected value is always +exposed through `.type`, just like `analysis.minimizer.type` and +`experiment.peak.type`. ```python -project.analysis.show_fitting_mode_types() -project.analysis.fitting_mode_type = 'sequential' +project.analysis.fitting_mode.show_supported() +project.analysis.fitting_mode.type = 'sequential' +print(project.analysis.fitting_mode.type) ``` The selector is backed by `FitModeEnum` and accepts: @@ -153,12 +168,14 @@ The selector is backed by `FitModeEnum` and accepts: - `joint` - `sequential` -`show_fitting_mode_types()` should show all fitting modes, mark the -current mode, and describe the execution requirements for each mode. It -should not hide `sequential` simply because the project currently has -only one experiment. Sequential fitting uses one template experiment -plus files from `sequential_fit.data_dir`, so filtering it out based on -experiment count is misleading. +`fitting_mode.show_supported()` should show all fitting modes and +describe the execution requirements for each mode. The active mode is +marked in the table; a separate show-current method is intentionally not +part of the public API. The supported list should not hide `sequential` +simply because the project currently has only one experiment. Sequential +fitting uses one template experiment plus files from +`sequential_fit.data_dir`, so filtering it out based on experiment count +is misleading. The selector changes the active fit mode and controls which mode-specific public categories are visible and serialized. @@ -166,12 +183,11 @@ mode-specific public categories are visible and serialized. Note that this is **not** the same mechanism as `peak_profile_type`. `peak_profile_type` swaps the concrete class behind a single category (`peak`); `fitting_mode_type` swaps which _sibling_ category -(`joint_fit` / `sequential_fit`) is active and visible. The `fitting` -category itself does not change shape. This is a new pattern — call it -the **active-sibling selector** — and it is documented here as a -first-class convention for owners that gate sibling categories on a -run-time choice. Future categories with the same shape should follow the -same naming and lifecycle rules. +(`joint_fit` / `sequential_fit`) is active and visible. This is the +**active-sibling selector** pattern, documented here as a first-class +convention for owners that gate sibling categories on a run-time choice. +Future categories with the same shape should follow the same naming and +lifecycle rules. ### 3. Keep mode-specific categories as flat Analysis siblings @@ -181,14 +197,14 @@ These categories are not nested under `fitting`. Public API: ```python -project.analysis.fitting_mode_type = 'joint' +project.analysis.fitting_mode.type = 'joint' project.analysis.joint_fit.create(experiment_id='sepd', weight=0.7) project.analysis.joint_fit.create(experiment_id='nomad', weight=0.3) project.analysis.fit() ``` ```python -project.analysis.fitting_mode_type = 'sequential' +project.analysis.fitting_mode.type = 'sequential' project.analysis.sequential_fit.data_dir = 'data/d20_scan' project.analysis.sequential_fit.file_pattern = '*.xye' project.analysis.sequential_fit.max_workers = 'auto' @@ -228,7 +244,7 @@ specified deterministically: - A `joint_fit` row whose `experiment_id` does not match any project experiment raises an error before fitting starts. It is not silently pruned, because that would mask user typos. -- Switching `fitting_mode_type` to `joint` does **not** auto-populate. +- Switching `fitting_mode.type` to `joint` does **not** auto-populate. Auto-population happens only at execution time so that intermediate configuration states are never silently mutated. @@ -419,17 +435,16 @@ categories are conditional workflow surfaces. The help output should show common analysis properties and only the category relevant to the active fit mode. -For `single` mode, help should show fitting configuration and the -`fit()` operation, but no joint or sequential category: +For `single` mode, help should show common analysis configuration and +the `fit()` operation, but no joint or sequential category: ```text Properties -fitting +minimizer display Methods fit() -show_fitting_mode_types() ``` For `joint` mode, help should additionally show: @@ -452,12 +467,12 @@ surface should only show categories relevant to the selected mode. ### 8. Serialize common and active mode-specific categories -Persist common fitting configuration in `analysis/analysis.cif` using a -category name that matches the new Python category: +Persist selector categories in `analysis/analysis.cif` using one +`_.type` tag per selector: ```cif -_fitting.minimizer_type "lmfit (leastsq)" -_fitting.mode_type sequential +_minimizer.type "lmfit (leastsq)" +_fitting_mode.type sequential ``` Persist only the active mode-specific category. @@ -465,8 +480,8 @@ Persist only the active mode-specific category. Sequential example: ```cif -_fitting.minimizer_type "lmfit (leastsq)" -_fitting.mode_type sequential +_minimizer.type "lmfit (leastsq)" +_fitting_mode.type sequential _sequential_fit.data_dir "data/d20_scan" _sequential_fit.file_pattern "*.xye" @@ -485,8 +500,8 @@ temperature diffrn.ambient_temperature "^TEMP\s+([0-9.]+)" false Joint example: ```cif -_fitting.minimizer_type "lmfit (leastsq)" -_fitting.mode_type joint +_minimizer.type "lmfit (leastsq)" +_fitting_mode.type joint loop_ _joint_fit.experiment_id @@ -498,8 +513,8 @@ nomad 0.3 Single example: ```cif -_fitting.minimizer_type "lmfit (leastsq)" -_fitting.mode_type single +_minimizer.type "lmfit (leastsq)" +_fitting_mode.type single ``` Inactive mode-specific categories should not be serialized. This avoids @@ -512,12 +527,13 @@ workflow, it is serialized only when the active fitting mode is Deserialization order must be: -1. restore the common `fitting` category -2. read `_fitting.mode_type` -3. set `analysis.fitting_mode_type` -4. restore the active mode-specific category, if present -5. restore active child collections such as `sequential_fit_extract` -6. restore other analysis categories such as aliases and constraints +1. read `_minimizer.type` +2. instantiate and restore `analysis.minimizer` +3. read `_fitting_mode.type` +4. set `analysis.fitting_mode.type` +5. restore the active mode-specific category, if present +6. restore active child collections such as `sequential_fit_extract` +7. restore other analysis categories such as aliases and constraints This mirrors the switchable-category restoration pattern used by experiment categories: the active mode is known before mode-specific @@ -547,8 +563,8 @@ new settings requires an explicit save step. ### Positive - `fit()` has one meaning: execute fitting. -- `fitting` has one meaning: common fitting configuration. -- Fit modes follow the same owner-level selection style as existing +- `minimizer.type` and `fitting_mode.type` live on their categories. +- Fit modes follow the same category-owned selector style as existing switchable categories. - `joint_fit` and `sequential_fit` are visible only when relevant. - Sequential fitting becomes runnable from CLI without a special Python @@ -559,7 +575,7 @@ new settings requires an explicit save step. public surfaces. - CIF structure is flat, explicit, and aligned with public API names. - Mode-specific configuration can grow independently without polluting - the common fitting category. + the common analysis surface. ### Trade-offs @@ -586,11 +602,14 @@ The following public API shapes are replaced by the new design: - `project.analysis.fit.mode` - `project.analysis.fit_sequential(...)` - `project.analysis.joint_fit_experiments` +- `project.analysis.fitting.minimizer_type` +- `project.analysis.minimizer_type` +- `project.analysis.fitting_mode_type` The replacement API is: -- `project.analysis.fitting.minimizer_type` -- `project.analysis.fitting_mode_type` +- `project.analysis.minimizer.type` +- `project.analysis.fitting_mode.type` - `project.analysis.joint_fit` - `project.analysis.sequential_fit` - `project.analysis.sequential_fit_extract` @@ -658,19 +677,18 @@ mode. It weakens help output and makes CIF harder to read. Rejected for the public API. -Although `_fitting.mode_type` is the CIF spelling, the public selector -should follow the existing switchable-category owner style: +Although `_fitting.mode_type` was the original CIF spelling, the public +selector should follow the category-owned switchable selector style: ```python -project.analysis.fitting_mode_type = 'sequential' +project.analysis.fitting_mode.type = 'sequential' ``` -A separate `fitting.mode` descriptor on the runtime `fitting` category -is also rejected: it would duplicate state already held by -`fitting_mode_type`. `_fitting.mode_type` is synthesized at -serialization time instead of being mirrored on a runtime object. +A separate `fitting.mode` descriptor on a runtime category is also +rejected: the accepted category is `fitting_mode`, not a resurrected +`fitting` intermediate. -### Replace the `fitting` category object per fit mode +### Replace a fitting category object per fit mode Rejected. @@ -679,12 +697,12 @@ directly, but switching by assigning a property on the object being replaced creates stale-reference hazards: ```python -fitting = project.analysis.fitting -project.analysis.fitting_mode_type = 'sequential' -# fitting may now point to the old object +mode_config = project.analysis.single_fit +project.analysis.fitting_mode.type = 'sequential' +# mode_config may now point to an inactive object ``` -Keeping `fitting` stable and adding active sibling mode categories gives +Keeping mode-specific categories as active siblings on `Analysis` gives better long-term API stability. ### Persist inactive mode-specific categories @@ -740,10 +758,10 @@ follow-up design topics that may need future ADRs if behaviour changes. token `auto`. Open: when CLI overrides resolve `auto` to a concrete integer for one run, is that integer ever written back, or is the token always preserved on disk regardless of runtime resolution? -- **Serialization order for `_fitting.*`.** \u00a79 specifies - deserialization order. Open: pin serialization order too (mode first, - then `minimizer_type`, then mode-specific siblings) so generated files - are stable for diffing? +- **Serialization order for selector categories.** \u00a79 specifies + deserialization order. Open: pin serialization order too (minimizer + type first, then fitting-mode type, then mode-specific siblings) so + generated files are stable for diffing? - **Failure mid-sequential-run.** Open: if `fit()` fails partway through a sequential scan, what is the state of `analysis/results.csv` and the persisted `sequential_fit` \u2014 resumable, discarded, or left as-is @@ -776,6 +794,10 @@ follow-up design topics that may need future ADRs if behaviour changes. - Optional `single_fit` category if single-mode-specific settings are introduced. -- A separate ADR for changing switchable category selectors globally - from owner-level names such as `peak_profile_type` toward - category-owned selectors such as `peak.profile_type`. + +## Resolved Follow-Up Work + +- [`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) + changes switchable selectors globally from owner-level names such as + `peak_profile_type` toward category-owned selectors such as + `peak.type`. diff --git a/docs/dev/adrs/accepted/minimizer-category-consolidation.md b/docs/dev/adrs/accepted/minimizer-category-consolidation.md new file mode 100644 index 000000000..f86b56a95 --- /dev/null +++ b/docs/dev/adrs/accepted/minimizer-category-consolidation.md @@ -0,0 +1,469 @@ +# ADR: Minimizer Category Consolidation + +## Status + +Accepted. + +## Date + +2026-05-23 + +## Group + +Analysis and fitting. + +## Context + +Recent Bayesian (DREAM) work introduced seven analysis-level categories +to persist Bayesian fit settings, results, diagnostics, per-parameter +summaries, and plot caches: + +- `_bayesian_sampler` (resolved sampler inputs) +- `_bayesian_result` (Bayesian header) +- `_bayesian_convergence` (diagnostics) +- `_bayesian_parameter_posterior` (per-parameter summaries) +- `_bayesian_distribution_cache`, `_bayesian_pair_cache`, + `_bayesian_predictive_dataset` (plot-ready cache manifests) + +This layout is internally consistent but breaks the convention used +everywhere else in the codebase: + +1. **One category per concept, plain descriptive name.** Structure and + experiment categories (`cell`, `peak`, `background`, `instrument`) + are single-concept and unsuffixed. The Bayesian work introduces a + parallel naming with prefixes (`bayesian_*`) and a settings/result + mirror that has no precedent. +2. **Refinement annotates the object in place.** A `Parameter` carries + both its user-set initial value and its fit-refined value plus + uncertainty on the same object. The Bayesian work stores per- + parameter posterior data in a separate loop category instead of on + the parameter. +3. **Selectors live on the owner.** `experiment.background_type` and + `experiment.peak_profile_type` are owner-level. The minimizer + selector lives one level deep at `analysis.fitting.minimizer_type`, + which has no analogous depth elsewhere. +4. **One user-input surface per concept.** Users today configure the + sampler at `analysis.fitting.minimizer.` (live solver instance + attributes), while the persisted `_bayesian_sampler.*` category is a + post-run snapshot with no public setters. The same fact lives in two + places, only one is writable, only the other appears in `help()` and + CIF. + +Adding emcee on top of this layout would entrench the divergence. This +ADR consolidates the design before introducing additional Bayesian +samplers. + +## Decision + +### 1. Unified `minimizer` category replaces all sampler-input and fit-result categories + +Introduce a single switchable category `minimizer` on `Analysis`. Its +concrete class is determined by `Analysis.minimizer_type`. The category +holds both user-writable inputs and fit-filled outputs in one place. + +The following categories are removed: + +- `bayesian_sampler` — fields move into the Bayesian concrete classes of + `minimizer`. +- `bayesian_result`, `bayesian_convergence` — fields move into the + Bayesian concrete classes of `minimizer` (`runtime_seconds`, + `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior`, …). +- `deterministic_result` — fields move into the deterministic concrete + classes of `minimizer` (`runtime_seconds`, `iterations_performed`, + `exit_reason`, …). +- `bayesian_parameter_posterior` — replaced by `Parameter.posterior` + (see §3). +- `bayesian_distribution_cache`, `bayesian_pair_cache`, + `bayesian_predictive_dataset` — replaced by HDF5 sidecar (see §4). + +`fit_result` and `fit_parameter` (analysis-owned bounds, success flag, +reduced chi-square, message, fit time, iterations) remain unchanged as +fit-mode-agnostic header categories. + +### 2. Selectors move to the `Analysis` owner + +The Python `fitting` category intermediate is dropped. `Analysis` +exposes: + +- `analysis.minimizer_type` (was `analysis.fitting.minimizer_type`) +- `analysis.fitting_mode_type` (was + `analysis.fitting.fitting_mode_type`) +- `analysis.minimizer` (the swappable category) +- `analysis.show_supported_minimizer_types()` +- `analysis.show_current_minimizer_type()` +- `analysis.show_supported_fitting_mode_types()` +- `analysis.show_current_fitting_mode_type()` +- `analysis.joint_fit`, `analysis.sequential_fit` (unchanged + active-sibling categories per + [`fit-mode-categories.md`](../accepted/fit-mode-categories.md)) + +CIF prefixes are unchanged: + +- `_fitting.minimizer_type`, `_fitting.mode_type` stay where they are. +- `_bayesian_sampler.*` is removed; the equivalent fields live under + `_minimizer.*`. + +Rationale: matches the +[Switchable Category API](../accepted/switchable-category-api.md) +convention used by `experiment.background_type` etc. — selector on the +owner, category as a read-only attribute that gets swapped. + +### 3. Per-parameter posterior data lives on `Parameter.posterior` + +Adopt the proposal from +[`parameter-posterior-summary.md`](parameter-posterior-summary.md): +`GenericParameter.posterior` is `None` for deterministic fits and a +`PosteriorParameterSummary` for Bayesian fits. The +`_bayesian_parameter_posterior` CIF loop is removed; posterior summary +columns are added to the existing `_fit_parameter` loop (one row per +refined parameter, mostly-empty columns when the fit was deterministic): + +- `_fit_parameter.posterior_best_sample_value` +- `_fit_parameter.posterior_median` +- `_fit_parameter.posterior_uncertainty` +- `_fit_parameter.posterior_interval_68_low`, + `posterior_interval_68_high` +- `_fit_parameter.posterior_interval_95_low`, + `posterior_interval_95_high` +- `_fit_parameter.posterior_gelman_rubin` +- `_fit_parameter.posterior_effective_sample_size_bulk` + +This mirrors `Parameter.uncertainty`: the same column structure is +populated by deterministic or Bayesian fits as appropriate. Per- +parameter posterior order is the order of the `_fit_parameter` rows +themselves; no separate parallel loop is needed. + +### 4. Heavy posterior arrays live in `analysis/results.h5`, not in CIF + +Posterior chains, KDE / distribution caches, pair-plot caches, and +predictive datasets are large arrays unsuited to CIF. The existing +`analysis/results.h5` sidecar absorbs all of them. The corresponding +manifest categories (`_bayesian_distribution_cache`, +`_bayesian_pair_cache`, `_bayesian_predictive_dataset`) are removed from +CIF entirely — the HDF5 file is self-describing. + +There is exactly **one** sidecar file per fit, regardless of minimizer: +`analysis/results.h5`. No CIF tag stores the sidecar path. The file uses +namespaced top-level groups: + +``` +analysis/results.h5 +├── /posterior/ # canonical posterior chains, log-prob (all Bayesian samplers) +├── /distribution_cache/ # KDE / 1-D distribution plots +├── /pair_cache/ # pair-plot grids +├── /predictive/ # posterior-predictive datasets +└── /emcee_chain/ # emcee HDFBackend live state (emcee runs only) +``` + +**Lifecycle rule: a new fit overwrites the file.** Mixing partial +results from different minimizers — or from the same minimizer with +different settings or a different free-parameter set — is the most +common source of "stale plot" confusion. To prevent this, calling +`analysis.fit()` truncates `analysis/results.h5` (recreating it with the +new run's groups). The user is shown a `log.warn(...)` message the first +time a fit is started while a populated sidecar exists, naming the file +and stating that previous results will be overwritten. + +Resume is the only exception: `analysis.fit(resume=True, extra_steps=N)` +opens the existing file in append mode and extends the chain. Resume is +rejected with a clear error if the active minimizer does not support it, +if `results.h5` is missing, or if the stored chain's parameter set does +not match the current one. + +For deterministic runs the Bayesian groups are absent and the sidecar +file may not exist at all. For non-emcee Bayesian runs the +`/emcee_chain` group is absent. + +### 5. Unified, verbose attribute names with internal mapping + +Each concrete `minimizer` class declares its descriptors in its class +body with verbose, dictionary-style names. Internally, each class maps +these names to its native backend keys. + +Stable inputs across Bayesian samplers (shared by DREAM and emcee): + +| Tag | Native (DREAM) | Native (emcee) | Description | +| ----------------------- | ---------------- | -------------- | -------------------------------------------------------- | +| `sampling_steps` | `steps` | `nsteps` | Total MCMC iterations per chain/walker | +| `burn_in_steps` | `burn` | `nburn` | Iterations discarded as warm-up | +| `thinning_interval` | `thin` | `thin` | Keep every Nth sample | +| `population_size` | `pop` | `nwalkers` | Number of chains / walkers | +| `parallel_workers` | `parallel` (int) | `pool` | `0` = all CPUs; `1` = serial; `N>1` = N worker processes | +| `initialization_method` | `init` (enum) | (custom) | Single unified enum (see §6) | +| `random_seed` | `random_seed` | `random_seed` | Random seed; `None` = system-derived | + +Bayesian-sampler-specific inputs: + +| Tag | Concrete class | Description | +| ---------------- | -------------- | ------------------------------------------- | +| `proposal_moves` | emcee only | emcee proposal moves (e.g. `stretch`, `de`) | + +Deterministic-LSQ inputs: + +| Tag | Description | +| ---------------- | ------------------------- | +| `max_iterations` | Maximum solver iterations | + +`random_seed` remains a Bayesian sampler input because the current +deterministic engines reject non-`None` random seeds. +`convergence_tolerance` is not exposed until a concrete engine path +actually consumes it. + +Fit-filled outputs (subset varies per class): + +| Tag | Class | Description | +| --------------------------- | -------- | ------------------------------------------- | +| `runtime_seconds` | all | Wall time of the fit | +| `reduced_chi2` | all | Reduced χ² | +| `iterations_performed` | LSQ | Iterations actually executed | +| `exit_reason` | LSQ | Free-form short string | +| `acceptance_rate_mean` | Bayesian | Mean acceptance rate across chains/walkers | +| `gelman_rubin_max` | Bayesian | Max R̂ across sampled parameters | +| `effective_sample_size_min` | Bayesian | Min effective sample size across parameters | +| `best_log_posterior` | Bayesian | Best log-posterior value found | + +Verbose CIF tags are user-facing. The canonical MCMC abbreviation +(`r_hat`, `n_eff`, `nllf`) is recorded in the descriptor's `description` +field so it appears in `help()` output but does not become a Python +attribute or a CIF tag. + +### 6. Unified `initialization_method` enum + +A single `(str, Enum)` `InitializationMethodEnum` with members: + +- `latin_hypercube` +- `ball` +- `uniform` +- `prior` + +Each concrete class accepts only the subset it supports and maps to its +native init mode (DREAM `lhs` ↔ `latin_hypercube`, emcee starting-state +generators ↔ `ball` / `uniform` / `prior`). Invalid combinations raise +at set time, not at fit time. + +### 7. CIF `?` is the universal "use default" marker + +Descriptors declare static defaults via `AttributeSpec(default=...)` +when each minimizer category instance is constructed. CIF behavior: + +- **Load.** A missing tag, or a tag with value `?`, resolves to the + descriptor's static default at load time. The category instance + carries the concrete default value from that moment on. +- **Save.** Always emit the actual value. Do not emit `?` for fields + that happen to equal the default. Round-trip is exact for any value + the user set; for an unset field, round-trip resolves `?` → default on + first load and emits the default on next save. +- **No callable defaults.** No "auto-resolve at fit time" (today's + `burn = steps // 5` is replaced by a fixed default + `burn_in_steps = 600`). If a default depends on other settings, the + dependency is documented; the user sets it explicitly. + +This rule applies to every descriptor, not just `minimizer`. For +descriptors that have no sensible default (e.g. `cell.length_a`), the +descriptor declaration omits `default=...` and CIF `?` continues to mean +"unknown" — a load-time error is raised when the field is read. + +### 8. Minimizer families carry defaults; warn-and-reset on swap + +Each concrete `minimizer` class has a complete, discoverable descriptor +surface. Descriptor instances are constructed from family helpers in +`__init__` so shared LSQ fields are declared once and sampler-specific +fields stay on the Bayesian concrete classes. Concrete subclasses may +override class-level defaults only when their backend behavior really +differs. + +```python +class EmceeMinimizer(BayesianMinimizerBase): + _default_sampling_steps = 5000 + _default_population_size = 32 + _default_proposal_moves = 'stretch' +``` + +When `analysis.minimizer_type` changes, the underlying instance is +replaced by a fresh instance of the new class with that class's +defaults. A `log.warn(...)` lists fields whose default values differ +between old and new classes, matching the precedent of `background_type` +swap warnings. + +### 9. Example CIF layouts + +`bumps (lm)`: + +``` +data_analysis + +_fitting.mode_type joint +_fitting.minimizer_type 'bumps (lm)' + +_minimizer.max_iterations 200 +_minimizer.runtime_seconds 12.34 +_minimizer.iterations_performed 87 +_minimizer.exit_reason converged +_minimizer.reduced_chi2 1.42 +``` + +`bumps (dream)`: + +``` +data_analysis + +_fitting.mode_type joint +_fitting.minimizer_type 'bumps (dream)' + +_minimizer.sampling_steps 3000 +_minimizer.burn_in_steps 600 +_minimizer.thinning_interval 1 +_minimizer.population_size 4 +_minimizer.parallel_workers 0 +_minimizer.initialization_method latin_hypercube +_minimizer.random_seed ? +_minimizer.runtime_seconds 124.7 +_minimizer.acceptance_rate_mean 0.27 +_minimizer.gelman_rubin_max 1.03 +_minimizer.effective_sample_size_min 482 +_minimizer.best_log_posterior -1234.56 +_minimizer.reduced_chi2 1.18 +``` + +`emcee` (added by the follow-up plan): + +``` +data_analysis + +_fitting.mode_type joint +_fitting.minimizer_type emcee + +_minimizer.sampling_steps 5000 +_minimizer.burn_in_steps 1000 +_minimizer.thinning_interval 5 +_minimizer.population_size 32 +_minimizer.proposal_moves stretch +_minimizer.parallel_workers 0 +_minimizer.initialization_method ball +_minimizer.random_seed 42 +_minimizer.runtime_seconds 87.3 +_minimizer.acceptance_rate_mean 0.31 +_minimizer.gelman_rubin_max 1.02 +_minimizer.effective_sample_size_min 612 +_minimizer.best_log_posterior -1237.89 +_minimizer.reduced_chi2 1.22 +``` + +emcee's resumable chain state lives in the `/emcee_chain` group of the +same `analysis/results.h5` file (see §4). No sidecar path appears in +CIF. + +## Superseded Selector Layout + +This ADR's original selector layout was superseded by +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md). +The minimizer selector no longer persists as `_fitting.minimizer_type` +and is no longer assigned through `analysis.minimizer_type`. The current +surface is: + +```python +analysis.minimizer.type = 'bumps (lm)' +analysis.minimizer.show_supported() +``` + +The active minimizer persists as `_minimizer.type`. The earlier +`_minimizer.optimizer_name` and `_minimizer.method_name` fields are also +dropped; restored `FitResults.optimizer_name` and +`FitResults.method_name` are derived from the concrete minimizer +category's class-level `_engine_metadata` dict. + +## Consequences + +### Architecture wins + +- The analysis layout matches the rest of the codebase: one descriptive + category per concept, selectors on owners, refinement-in-place. +- The Bayesian / deterministic split stops requiring parallel category + trees. One swappable `minimizer` covers both worlds. +- Adding new minimizers (emcee, future samplers, future LSQ variants) is + a one-class change: declare descriptors, register with the factory. +- CIF projects shrink: large arrays move to HDF5; redundant manifest + categories disappear. + +### Trade-offs + +- `minimizer` is the first category that mixes writable user inputs and + writable fit-filled outputs in the same scope. This is a small new + convention but is the natural generalization of how `Parameter` + already holds both user input and refined value on the same object. +- The set of `_minimizer.*` tags present in CIF depends on the active + `_fitting.minimizer_type`. Loading a CIF whose tags don't match the + minimizer's allowed set raises (clear validation, not silent + ignoring). +- Hand-editing CIF to switch minimizer types requires touching both + `_fitting.minimizer_type` and the relevant `_minimizer.*` tags. +- Existing projects saved under the seven-category layout cannot load + unchanged. The project is in beta; per + `.github/copilot-instructions.md` "no legacy shims" applies. Saved + fixtures under `tmp/tutorials/projects/` are regenerated by the + implementation plan. + +### ADRs amended by this ADR + +- [`runtime-fit-results.md`](../accepted/runtime-fit-results.md) — amend + the closing line to point at this ADR as the canonical + saved-projection definition (alongside `analysis-cif-fit-state.md`). +- [`analysis-cif-fit-state.md`](../accepted/analysis-cif-fit-state.md) — + replace §"Bayesian fit projection" entirely. Remove the seven + `_bayesian_*` categories; describe `_minimizer.*` and the extended + `_fit_parameter` posterior columns. Remove the sidecar-path CIF field; + the sidecar name is implicit. +- [`fit-mode-categories.md`](../accepted/fit-mode-categories.md) — + update §1 and §2 to reflect that `minimizer_type` and + `fitting_mode_type` live on `Analysis` directly, not on a `fitting` + Python intermediate. The active-sibling design for `joint_fit` / + `sequential_fit` is unchanged. +- [`selector-families.md`](../accepted/selector-families.md) — + reclassify `analysis.minimizer_type` as a switchable-category selector + (on owner `Analysis`, swaps the `minimizer` category instance), no + longer a Backend selector. +- [`switchable-category-api.md`](../accepted/switchable-category-api.md) + — append `minimizer` to the examples list. No mechanical change. +- [`parameter-correlation-persistence.md`](../accepted/parameter-correlation-persistence.md) + — verify wording still applies (categories + `_fit_parameter_correlation` are kept by this ADR; should be a no-op). + +### Suggestions superseded or absorbed + +- [`parameter-posterior-summary.md`](parameter-posterior-summary.md) — + absorbed by §3 of this ADR. When this ADR is accepted, that suggestion + can be closed and a pointer added. + +## Alternatives Considered + +### A. Keep `bayesian_settings` and `least_squares_settings` as separate categories + +Two stable input categories, each switchable internally. Rejected +because it (i) introduces the `_settings` suffix convention that has no +precedent in the codebase, (ii) duplicates the input/output mirror +pattern, and (iii) gains nothing over a single owner-level category +whose shape adapts to the active minimizer. + +### B. Single flat `fit_settings._` category + +One namespace, attributes prefixed by family. Rejected because (i) it +forces long attribute names (`fit_settings.bayesian_population_size`), +(ii) breaks the "one category, one focused concept" convention, and +(iii) loses the natural shape-shifting that `background` and +`peak_profile` already exemplify. + +### C. Keep the seven-category Bayesian layout and add emcee siblings + +Add `_emcee_sampler`, `_emcee_convergence`, …, mirroring the existing +`_bayesian_*` layout per backend. Rejected because it doubles the +category count for each new sampler and entrenches the convention break. + +### D. Strict input-only `minimizer` plus a separate `fit_result` + +Keep the categories single-concept (inputs xor outputs) at the cost of +two-place lookup for related info. Rejected in favour of the +one-category-mixes-both shape (§1, §"Trade-offs") because the existing +`Parameter` model already mixes input and refined value on the same +object, and one-place discoverability is more valuable than strict +purity. diff --git a/docs/dev/adrs/accepted/runtime-fit-results.md b/docs/dev/adrs/accepted/runtime-fit-results.md index f7ee8389e..282b9841d 100644 --- a/docs/dev/adrs/accepted/runtime-fit-results.md +++ b/docs/dev/adrs/accepted/runtime-fit-results.md @@ -25,9 +25,14 @@ Persist fit configuration, not full runtime fit results. Per-experiment calculator selection lives in experiment files. Common fit configuration and fit-mode settings live in `analysis/analysis.cif`. -Runtime fit outputs such as `analysis.fit_results`, posterior samples, -posterior predictive arrays, summaries, and diagnostics remain -runtime-only unless a later ADR narrows the persisted projection. +Runtime fit outputs such as `analysis.fit_results`, backend objects, and +raw driver payloads remain runtime-only unless a narrower ADR defines a +persisted projection. The accepted +[`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) and +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +ADRs define the current compact projection for fit headers, +minimizer-owned outputs, parameter posterior summaries, and the +`analysis/results.h5` sidecar. ## Consequences diff --git a/docs/dev/adrs/accepted/selector-families.md b/docs/dev/adrs/accepted/selector-families.md index 698991649..66fb655ff 100644 --- a/docs/dev/adrs/accepted/selector-families.md +++ b/docs/dev/adrs/accepted/selector-families.md @@ -20,18 +20,35 @@ would blur distinct concepts. ## Decision +This ADR is amended by +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md). +The family names now describe the owner-side mechanism, not distinct +public selector surfaces. All three families share the same public +shape: + +```python +category.type = 'new-type' +category.show_supported() +``` + +The owner exposes the category itself and implements a private +`_swap_` hook. That hook determines whether the assignment swaps a +category instance, rebinds a live backend behind a singleton category, +or activates sibling categories. + Recognize three selector families: -| Family | User intent | Examples | -| ---------------------------- | ------------------------------- | --------------------------------------------------------------------------------- | -| Backend selector | Pick an execution backend | `fitting.minimizer_type`, `calculation.calculator_type`, `rendering.chart_engine` | -| Switchable-category selector | Swap a category implementation | `experiment.background_type`, `experiment.peak_profile_type` | -| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode_type` | +| Family | User intent | Examples | +| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------- | +| Backend selector | Pick an execution backend | `experiment.calculator.type`, `project.chart.type`, `project.table.type` | +| Switchable-category selector | Swap a category implementation | `analysis.minimizer.type`, `experiment.background.type`, `experiment.peak.type` | +| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode.type` | Backend selectors live on dedicated configuration categories. -Switchable-category selectors live on the host because they replace a -category instance. Active-sibling selectors live on the owner and decide -which sibling categories are visible, authoritative, and serialized. +Switchable-category selectors live on the category they replace, and the +owner swaps the instance behind the same public property. Active-sibling +selectors also live on a category and the owner decides which sibling +categories are visible, authoritative, and serialized. ## Consequences diff --git a/docs/dev/adrs/accepted/switchable-category-api.md b/docs/dev/adrs/accepted/switchable-category-api.md index 629f01f9b..09c367ea0 100644 --- a/docs/dev/adrs/accepted/switchable-category-api.md +++ b/docs/dev/adrs/accepted/switchable-category-api.md @@ -15,24 +15,44 @@ User-facing API. ## Context Some categories have multiple concrete implementations that users can -switch at runtime, such as background, peak profile, and extinction. -Other categories are fixed by experiment type or have only one current -implementation. +switch at runtime, such as background, peak profile, extinction, and the +analysis minimizer. Other categories are fixed by experiment type or +have only one current implementation. ## Decision -For multi-type switchable categories, expose the selector on the owner: +This ADR is amended by +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md). +The current public selector contract is: + +- the owner exposes the category object only, for example + `analysis.minimizer` or `experiment.background` +- the category exposes the writable `type` property +- the category exposes `show_supported()`, with the active row marked by + `*` +- fixed-at-creation categories and single-implementation categories do + not expose a public selector + +The older owner-level surface below is historical context and is +superseded by that accepted ADR. + +### Historical owner-level decision + +For multi-type switchable categories, the original decision exposed the +selector on the owner: ```python +analysis.minimizer_type = 'bumps (dream)' experiment.background_type = 'chebyshev' experiment.peak_profile_type = 'pseudo-voigt' ``` -The category object itself remains a read-only property. Switching the -owner-level type replaces the underlying category object. +The category object itself remained a read-only property. Switching the +owner-level type replaced the underlying category object. -Expose `show__types()` on the owner so supported choices can -be filtered by the owner context. +The original design exposed `show_supported__types()` and +`show_current__type()` on the owner so supported choices could +be listed separately from the active choice. Do not expose public `_type` selectors for fixed-at-creation categories or single-implementation categories. Their factories and internal type diff --git a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md new file mode 100644 index 000000000..19fb491bb --- /dev/null +++ b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md @@ -0,0 +1,1041 @@ +# ADR: Switchable Category Owned Selectors + +## Status + +Accepted. + +## Date + +2026-05-23 + +## Group + +User-facing API and CIF mapping. + +## Context + +Today every switchable category — `analysis.minimizer`, +`experiment.background`, `experiment.peak`, `experiment.extinction`, … — +exposes its selector at owner level per +[`switchable-category-api.md`](switchable-category-api.md): + +```python +analysis.minimizer_type = 'bumps (lm)' +analysis.minimizer # read-only +analysis.show_supported_minimizer_types() # method on owner +analysis.show_current_minimizer_type() # method on owner +``` + +Three problems have accumulated since that ADR landed: + +1. **Two-scope UX.** The category instance lives at `analysis.minimizer` + (read-only). The thing you assign lives at `analysis.minimizer_type` + (writable). Every supported show-method is on the owner. A scientist + looking at `analysis.minimizer.help()` sees the configuration + descriptors but cannot change the active backend from that surface; + they have to go back up one level. The same split applies to + background, peak, etc. + +2. **CIF duplication and inconsistency.** The owner-level convention + persists a tag like `_fitting.minimizer_type = 'bumps (lm)'` _and_ + the swapped category records its identity again — e.g. + `_minimizer.optimizer_name = 'bumps (lm)'`. The two values are by + construction equal; one of them is dead weight in every saved + project. The codebase is also internally inconsistent in how it + persists the active type across switchable categories: + `_peak.profile_type` is an in-category identity tag (so picking a + peak profile already lives entirely inside the `_peak.*` block); + `_background.*` has no identity tag at all today — the active type is + inferred at load time from which `_pd_background.*` loop columns are + present; `_fitting.minimizer_type` lives in the owner-level + `_fitting.*` block separately from the `_minimizer.*` block that the + swapped category writes; `_calculation.calculator_type` lives inside + its category block but the descriptor name awkwardly repeats the noun + ("calculator") instead of using a uniform `.type` selector. + [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + notes the inconsistency under §"Owner-level switchable selectors" and + tags it for a future ADR. + +3. **Cross-cutting inconsistency.** Issue [#76](../../issues/open.md) + ("Consistent `_type` suffix in switchable-category API names") + tracked the inconsistency in the _method names_ on the owner, but + assumed the owner-level model stayed. + [`fit-mode-categories.md`](fit-mode-categories.md) §"Deferred Work" + explicitly records: + + > "A separate ADR for changing switchable category selectors globally + > from owner-level names such as `peak_profile_type` toward + > category-owned selectors such as `peak.profile_type`." + + This ADR is that follow-up. + +The +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +work just landed; it intentionally preserved `_fitting.minimizer_type` +because the broader convention had not yet been amended. With that PR +merged, the path is clear to amend the convention now. + +## Decision + +### 1. The category owns its selector + +Every in-scope selector category — across all three mechanism families +recognised by [`selector-families.md`](selector-families.md) (A +switchable categories, B backend selectors, C active-sibling selectors) +— exposes the same writable surface: + +```python +category.type # writable property (str) +category.show_supported() # one method, current marked with '*' +``` + +That is the entire public selector surface. Nothing else. Setting +`category.type = 'X'` delegates to the owner's `_swap_` hook; what +happens behind that hook depends on the family — the owner replaces the +category instance (Family A), rebinds the live engine behind a singleton +category (Family B), or activates / deactivates sibling categories +(Family C). The user-facing API does not change with the mechanism. See +§6 for the full scope and the mechanism-vs- surface framing. + +Owner-level shims are removed (no `._type`, no +`show_supported__types()`, no `show_current__type()`). The +owner exposes only the category itself, e.g. `analysis.minimizer`. + +The owner still owns the swap mechanism (it holds the slot) but the swap +is _initiated_ from the category through a back-reference. + +### 2. `show_current()` is intentionally omitted + +`category.show_supported()` marks the active type with `'*'` in its +table, so a dedicated "show current" method would print a strict subset +of the same information. The machine-readable accessor is +`category.type`. Three paths cover every reasonable user need: + +| User wants | API | +| ---------------------------------- | --------------------------- | +| The active type as a string | `category.type` | +| The active type printed | `print(category.type)` | +| The full table, active row starred | `category.show_supported()` | + +A separate `show_current()` method is therefore not added, and the +existing owner-level `show_current__type()` methods are removed in +step with the rest of this ADR. + +### 3. CIF mapping is collapsed to one `_.type` tag per category + +Every selector — across all three families that present a writable type +surface — persists exactly one identity tag, `_.type`. Owner-level +selector tags are dropped. Per-category identity-echo tags are renamed +to the uniform spelling. Three CIF block names are new (`_chart`, +`_table`, `_calculator`) because the `Rendering` category is split and +the `Calculation` category is renamed (see §8); one is new +(`_fitting_mode`) because the active-sibling selector is promoted to its +own category (also §8). + +| Today | Replacement | Mechanism family ¹ | +| ------------------------------------- | -------------------- | ------------------ | +| `_fitting.minimizer_type` | `_minimizer.type` | A | +| `_peak.profile_type` | `_peak.type` | A | +| (none — only `_pd_background.*` loop) | `_background.type` | A | +| (none — only active-class fields) | `_extinction.type` | A | +| `_calculation.calculator_type` | `_calculator.type` | B (and §8 rename) | +| `_rendering.chart_engine` | `_chart.type` | B (and §8 split) | +| `_rendering.table_engine` | `_table.type` | B (and §8 split) | +| `_fitting.mode_type` | `_fitting_mode.type` | C (and §8 promote) | + +¹ Mechanism family per [`selector-families.md`](selector-families.md): A +swaps the category instance, B swaps the live engine behind a singleton +category, C activates or deactivates sibling categories. The user- +facing CIF and Python surface is **identical** across all three. + +After the consolidation: the `_fitting.*` and `_rendering.*` CIF blocks +**disappear entirely**. `_fitting` was a heterogeneous bag holding two +unrelated selectors; `_rendering` did the same. Both are split into +single-purpose blocks aligned with the new `_.type` rule. + +`_minimizer.optimizer_name` and `_minimizer.method_name` are also +**dropped**. Inspecting +[`src/easydiffraction/analysis/minimizers/lmfit_leastsq.py`](../../../src/easydiffraction/analysis/minimizers/lmfit_leastsq.py) +(and the matching `bumps_lm.py`, `dfols.py`, …) shows that `name` +defaults to the enum tag itself and `method` to a per-engine +module-level constant. The public API never overrides them at +construction. So the persisted values are deterministic functions of the +tag, not independent observations — exactly the duplication that +motivated the ADR. + +The runtime `FitResults.optimizer_name` and `FitResults.method_name` are +derived on restore from a class-level metadata dict declared on each +concrete minimizer category: + +```python +class LmfitLeastsqMinimizer(LeastSquaresMinimizerBase): + type_info = TypeInfo( + tag='lmfit (leastsq)', + description='LMFIT library with Levenberg-Marquardt least squares method', + ) + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'lmfit (leastsq)', + 'method_name': 'leastsq', + } +``` + +Restore reads `type(self.minimizer)._engine_metadata` — no engine +instance construction is needed, and the engine modules themselves do +not need to grow class-level mirrors of their module-level +`DEFAULT_METHOD` constants. The dict lives on the category class where +`type_info.description` already lives, keeping all per-tag metadata in +one place. [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) is +amended to drop these two fields from the persisted projection and to +point at the class-level dict as the new restore source. + +### 4. Mechanism: behavior-only mixin + parent-side swap hook + +`SwitchableCategoryBase` is a **behavior-only mixin**, not an +intermediate in the storage hierarchy. It carries no descriptor +instances and does no `__init__` work, which keeps it compatible with +both singleton categories (extending `CategoryItem`) and +collection-shaped categories (extending `CategoryCollection`): + +```python +class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): ... +class PeakBase(CategoryItem, SwitchableCategoryBase): ... +class ExtinctionBase(CategoryItem, SwitchableCategoryBase): ... +class BackgroundBase(CategoryCollection, SwitchableCategoryBase): ... +``` + +This is consistent with +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +§8 "no mixins" — that rule rejects mixins **for descriptor +declarations**; behavior mixins are allowed (the +`LeastSquaresMinimizerBase` / `BayesianMinimizerBase` intermediates +introduced in P1.4 are the precedent). + +The mixin itself: + +```python +class SwitchableCategoryBase: + """Behavior-only mixin for instance-swap switchable categories.""" + + _parent: object | None = None + _category_code: ClassVar[str] # e.g. 'minimizer' + _owner_attr_name: ClassVar[str] # e.g. 'minimizer' + _swap_method_name: ClassVar[str] # e.g. '_swap_minimizer' + + @property + def type(self) -> str: + """Active factory tag for this category.""" + return self._type.value + + @type.setter + def type(self, value: str) -> None: + if self._parent is None: + msg = ( + f'{type(self).__name__} is detached; ' + 'cannot change type on a stale instance.' + ) + raise RuntimeError(msg) + live = getattr(self._parent, self._owner_attr_name) + if live is not self: + msg = ( + f'{type(self).__name__} is no longer the live ' + f'category on its owner; obtain a fresh reference ' + f'via owner.{self._owner_attr_name}.' + ) + raise RuntimeError(msg) + canonical = self._canonicalize(value) + getattr(self._parent, self._swap_method_name)(canonical) + + def _canonicalize(self, value: str) -> str: + """Resolve a user-supplied tag to its canonical factory tag. + + Default: identity. Categories with a context-local alias + system (currently `peak` only) override this to map aliases + such as ``'pseudo-voigt'`` to canonical tags such as + ``'cwl-pseudo-voigt'`` using the owner's context (beam mode). + CIF persists the canonical tag, so round-trips are stable + regardless of context shifts. See §"Aliases" below. + """ + return value + + def _supported_types( + self, filters: dict[str, object] + ) -> list[tuple[str, str]]: + """Return ``[(tag, description), ...]`` for supported types. + + Subclasses MUST implement. The mixin's ``show_supported()`` + renders the returned pairs into a table. Three concrete + shapes coexist depending on what backs the category — a + domain factory, a renderer factory, or a plain enum (see + §"Three supported-type shapes" below). + """ + raise NotImplementedError + + def show_supported(self) -> None: + """Show supported types for this category. + + Renders one ``['*', tag, description]`` row per supported + type, with the currently active type marked ``'*'``. The + owner contributes only a filter dict (calculator, beam mode, + …); rendering itself lives here so the table shape is + uniform across every switchable in the project. + """ + filters = ( + self._parent._supported_filters_for(self) + if self._parent is not None + else {} + ) + current = self.type + columns_data = [ + ['*' if tag == current else '', tag, description] + for tag, description in self._supported_types(filters) + ] + console.paragraph(f'{type(self).__bases__[0].__name__} types') + render_table( + columns_headers=['', 'Type', 'Description'], + columns_alignment=['left', 'left', 'left'], + columns_data=columns_data, + ) +``` + +#### Three supported-type shapes + +The mixin's `_supported_types()` is abstract because the project hosts +three different backings for switchable categories. Each concrete base +picks the shape that matches its backing — two or three lines, no +abstraction in the mixin itself. + +```python +# Shape 1 — domain factories (FactoryBase API): +# minimizer, peak, background, extinction, calculator +class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): + def _supported_types(self, filters): + return [ + (cls.type_info.tag, cls.type_info.description) + for cls in MinimizerCategoryFactory.supported_for(**filters) + ] + +# Shape 2 — renderer factories (RendererFactoryBase API): +# chart, table. The 'auto' sentinel is part of the supported set +# alongside the concrete engines, so it appears as a row in the +# table and gets the '*' mark when chart.type / table.type is +# currently 'auto' (which is the default value per +# CHART_ENGINE_OPTIONS / TABLE_ENGINE_OPTIONS). +class ChartBase(CategoryItem, SwitchableCategoryBase): + _auto_description: ClassVar[str] = ( + 'Pick a backend automatically based on environment' + ) + + def _supported_types(self, filters): + # PlotterFactory.descriptions() returns list[tuple[str, str]] + # already in (engine, description) shape. + return [('auto', self._auto_description), *PlotterFactory.descriptions()] + +# Shape 3 — plain enum (no factory): fitting_mode +class FittingModeBase(CategoryItem, SwitchableCategoryBase): + def _supported_types(self, filters): + return [(mode.value, mode.description()) for mode in FitModeEnum] +``` + +The five Shape-1 categories share an identical body; if duplication +becomes a problem in implementation, factor it into a small base helper. +The two Shape-2 categories also share an identical body. Shape 3 has +exactly one user. No upstream changes to factories or enums are +required. + +#### Aliases + +Categories with a context-local alias system (currently `peak` only) +override `_canonicalize()` to resolve user-supplied aliases to canonical +factory tags. The setter applies canonicalization before delegating to +the owner's swap hook, so the underlying descriptor and the persisted +CIF tag are always canonical: + +```python +class PeakBase(CategoryItem, SwitchableCategoryBase): + def _canonicalize(self, value: str) -> str: + beam_mode = self._parent.type.beam_mode.value + return _canonicalize_peak_profile_type(value, beam_mode) +``` + +`show_supported()` defaults to two-column rendering (tag, description). +Categories that want to show aliases alongside canonical tags (peak) +override `show_supported()` to add a third column; the per-category +override pattern is the same as the existing +[`base.show_peak_profile_types()`](../../../src/easydiffraction/datablocks/experiment/item/base.py) +implementation, just moved onto the category. + +`type` is backed by a **real `StringDescriptor`** named `_type` that +each concrete-base `__init__` constructs with +`cif_handler=CifHandler(names=[f'_{category_code}.type'])` and a +membership validator over the factory's supported tags. The descriptor +is what serializes; the property is the user-facing writable hook with +the staleness checks. + +For `CategoryItem` substrates (minimizer, peak, extinction, calculator, +chart, table, fitting_mode) the generic CIF emit/read path +[`io/cif/serialize.py:170`](../../../src/easydiffraction/io/cif/serialize.py) +picks the descriptor up by name automatically — no custom hook is +needed. For the `CategoryCollection` substrate (background only), +[`category.py:230`](../../../src/easydiffraction/core/category.py)'s +`parameters` returns only loop-item parameters and +[`io/cif/serialize.py:244`](../../../src/easydiffraction/io/cif/serialize.py) +writes only the loop, so a collection-level `_type` descriptor needs a +small additional path: the writer emits the scalar tag above the loop, +and the reader peeks the scalar before iterating items. This is a +**one-time generalization of the collection serializer** to support +collection-level scalar descriptors that sit alongside the loop; the +change is reusable by any future collection-shaped switchable. + +CIF format note. The CIF specification allows scalar tags and loop tags +to share a category prefix in the same block as long as no individual +tag is duplicated; gemmi handles this correctly (verified empirically by +reading a `_background.type chebyshev` scalar alongside a +`_background.Chebyshev_order` / `_background.Chebyshev_coef` loop in the +same block — see Reply 2 F1 for the test script). The collection-shaped +`background` row of the catalog therefore persists the scalar selector +on `_background.type` while the existing loop columns stay on their +current `_pd_background.*` prefix (IUCr pd_CIF convention); the Python +`BackgroundBase` collection owns both CIF prefixes — one for its scalar +selector, one for its row data. + +The owner provides two private hooks per switchable category. First, a +`_swap_` method that performs the swap and **detaches the old +instance** before installing the new one, so a stale reference cannot +accidentally re-trigger another swap. Second, a single +`_supported_filters_for(category)` dispatch that returns the filter dict +for any of the owner's categories: + +```python +class Analysis: + def _swap_minimizer(self, new_type: str) -> None: + new_minimizer = MinimizerCategoryFactory.create(new_type) + self._warn_about_minimizer_swap_defaults( + self._minimizer, new_minimizer, + ) + self._minimizer._parent = None # detach old + new_minimizer._parent = self + self._minimizer = new_minimizer + self._fitter = Fitter(new_type) + + def _supported_filters_for( + self, category: SwitchableCategoryBase + ) -> dict[str, object]: + # analysis switchables have no context filters today + return {} + + +class ExperimentBase: + def _supported_filters_for(self, category): + calculator = self.calculator.type + if category is self.background: + return {'calculator': calculator} + if category is self.extinction: + return {'calculator': calculator} + if category is self.peak: + return { + 'calculator': calculator, + 'scattering_type': self.type.scattering_type.value, + 'sample_form': self.type.sample_form.value, + 'beam_mode': self.type.beam_mode.value, + } + return {} +``` + +The existing owner-level `show__types()` methods +([`bragg_pd.show_background_types()`](../../../src/easydiffraction/datablocks/experiment/item/bragg_pd.py), +[`base.show_peak_profile_types()`](../../../src/easydiffraction/datablocks/experiment/item/base.py), +`Calculation.show_calculator_types()`, +`Analysis.show_supported_minimizer_types()`, …) are **deleted**. The +mixin's `show_supported()` reproduces the same `['*', tag, description]` +table shape that all of them produce today, so the user-facing output is +unchanged; only the entry point moves from the owner onto the category. +Owners contribute only the filter dict via +`_supported_filters_for(category)`. + +The Family-B swap hooks (e.g. `Experiment._swap_calculator`, +`Project._swap_chart`, `Project._swap_table`) follow the same shape but +rebind the live engine rather than the category instance. The Family-C +swap hook (`Analysis._swap_fitting_mode`) performs the existing +sibling-activation logic. The mixin does not care which mechanism the +owner uses; it only routes the writable surface. + +CIF read path becomes: + +1. Owner peeks `_.type` from the CIF block. +2. Owner calls `_swap_(value)` to install the matching concrete + class — its `_type` descriptor is now initialised to the persisted + value. +3. Generic CIF deserialization populates the remaining descriptors on + the newly-installed instance. + +This is the same shape as +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +P1.7's peek-then-populate flow; the only change is that the type tag +lives inside the category's own namespace. + +### 5. Stale-reference safety + +After a swap the old instance is **detached** (`_parent = None`), and +the `type` setter checks both attachment and slot liveness (see §4). Two +stale-reference scenarios collapse to a clear error rather than silent +mutation: + +```python +m = project.analysis.minimizer # binds to instance A +project.analysis.minimizer.type = 'bumps (dream)' # A is detached, B installed +m.type = 'lmfit' # ⇒ RuntimeError: detached / stale +``` + +Descriptor writes on the detached instance (`m.sampling_steps = 3000`) +still mutate the orphan — that is the unavoidable Python semantics for +any held reference — but the **type-swap path** is now safe from +re-triggering the parent. The intended scientist workflow goes through +the live property each time: + +```python +project.analysis.minimizer.type = 'bumps (dream)' +project.analysis.minimizer.sampling_steps = 3000 +``` + +A future refactor could additionally emit a `log.warn(...)` from +descriptor setters on detached instances (so even +`m.sampling_steps = 3000` flags the stale-orphan write); the design is +recorded as a follow-up but is not part of this ADR. + +### 6. Scope: every selector that presents a writable type surface + +This ADR applies to every selector whose public Python surface is "set a +type / pick from a supported list", regardless of what happens behind +the setter. Three mechanism families per +[`selector-families.md`](selector-families.md) all present the **same** +writable `category.type` surface and the same +`category.show_supported()` API; the mechanism differs only in what the +owner's `_swap_` hook does behind that surface. + +**In scope:** + +- **Family A — switchable-category selectors:** `minimizer`, + `background`, `peak` (currently exposed as `peak_profile`), + `extinction`. Owner's `_swap_` replaces the category instance + via the matching factory. +- **Family B — backend selectors:** `calculator` (today the + awkwardly-named `experiment.calculation.calculator_type`; the Python + class `Calculation` is renamed to `Calculator` and the category + attribute moves from `experiment.calculation` to + `experiment.calculator` — see §8c), `chart` and `table` (split out of + the current `rendering` category — see §8a). Owner's `_swap_` + keeps the category singleton and rebinds the live engine instead. The + user-facing API surface is identical to Family A. +- **Family C — active-sibling selector:** `fitting_mode` (today the bare + `analysis.fitting_mode_type` descriptor; promoted to its own small + `FittingMode` category — see §8). Owner's `_swap_` activates / + deactivates sibling categories (`joint_fit` / `sequential_fit` / + `sequential_fit_extract`) based on the new value. The user-facing API + surface is identical to Family A. + +The categories with a single factory tag and no second concrete type +registered today (e.g. `aliases`, `constraints`, `cell`, `space_group`, +…) are not in scope. They inherit the convention automatically when a +second type is added. + +**Out of scope:** + +- **Plain enum descriptors:** `experiment.type.sample_form`, + `experiment.type.beam_mode`, `experiment.type.radiation_probe`, + `experiment.type.scattering_type` (creation-time axes per + [`immutable-experiment-type.md`](immutable-experiment-type.md)); + `atom_site.adp_type` (governed by + [`type-neutral-adp-parameters.md`](type-neutral-adp-parameters.md)); + `extinction.becker-coppens.model` (a closed-set enum nested inside the + Family-A `extinction` category — local to the selected extinction + class). These select a value, not a type or backend, and do not + present a `.type` writable surface. + +The plan that implements this ADR enumerates the exact set of affected +categories and their swap hooks at the start of Phase 1. + +### 7. Beta posture: hard cutover, no shims + +[`.github/copilot-instructions.md`](../../../../.github/copilot-instructions.md) +→ **Change Discipline**: "Project is in beta: no legacy shims, no +deprecation warnings — update tests and tutorials to the current API." +This ADR keeps that posture: + +- `._type` is **deleted**, not deprecated. +- `show_supported__types()` / `show_current__type()` are + deleted. +- Owner-level CIF selector tags are deleted. +- Identity-echo CIF fields are deleted. +- Every tutorial, test, and example CIF is migrated. + +Existing saved projects under `tmp/tutorials/projects/*` regenerate from +script tests, matching the precedent set by +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md). + +### 8. Three structural changes beyond pure renames + +Three selectors need structural changes to fit the `category.type` rule +under [`category-parameter-access.md`](category-parameter-access.md)'s +two-level parameter access (`datablock.category.parameter`) and the +"category name matches the noun of the thing being selected" convention +shared by every other in-scope category (`minimizer`, `peak`, +`background`, …). + +#### 8a. `Rendering` → `Chart` + `Table` + +Today `project.rendering` is a single category holding two engine +selectors (`chart_engine`, `table_engine`) plus two live facades +(`plotter`, `tabler`). The two-level rule blocks +`project.rendering.chart.type` and `project.rendering.table.type` (three +levels). + +The `Rendering` category is **removed**. Two new sibling categories +appear on `Project`: + +- `project.chart` — `CategoryItem` with one writable selector `type` + (`PlotterEngineEnum` plus the `'auto'` sentinel) and the live + `Plotter` facade as a private internal. CIF block: `_chart.*`. +- `project.table` — `CategoryItem` with one writable selector `type` + (`TableEngineEnum` plus `'auto'`) and the live `TableRenderer` facade + as a private internal. CIF block: `_table.*`. + +Both follow the §4 mechanism — Family B (engine swap), `category.type` +surface — and become natural homes for future chart-only and table-only +descriptors (e.g. `chart.height`, `chart.theme`, `table.max_rows`, +`table.precision`). + +The owner-level `project.rendering.show_chart_engines()`, +`project.rendering.show_table_engines()`, and +`project.rendering.show_config()` methods are deleted. Their +replacements are `project.chart.show_supported()`, +`project.table.show_supported()`, and (if needed) a thin +`project.show_config()` that prints both categories' current state. + +#### 8b. `analysis.fitting_mode_type` → `analysis.fitting_mode.type` + +Today `analysis.fitting_mode_type` is a bare `FitModeEnum` descriptor +sitting directly on `Analysis`. Not a category. The two-level rule +permits `analysis.fitting_mode.type` if we promote it. + +A new minimal `FittingMode` category is added under `Analysis`: + +- One descriptor: `type` (`FitModeEnum`-valued, defaults to + `FitModeEnum.SINGLE`). +- One swap hook on the owner: `Analysis._swap_fitting_mode(new_value)` + which performs the existing sibling-activation work (controls which of + `joint_fit` / `sequential_fit` / `sequential_fit_extract` is visible + to `help()`, written by the serialiser, and used at fit time). +- CIF block: `_fitting_mode.*` (currently `_fitting.mode_type`). + +Today the category has only one descriptor. Future mode-wide settings +(e.g. parallel-independent-fit knobs from open issue #89) have a natural +home there. The "don't introduce abstractions before a second concrete +use" rule from CLAUDE.md is satisfied here because the abstraction we +are introducing is the **uniform `category.type` surface across the +project**, not a one-off `FittingMode` class. + +#### 8c. `Calculation` → `Calculator` + +Today `experiment.calculation` is a singleton category holding one +descriptor named `calculator_type` (CIF tag +`_calculation.calculator_type`). The category name does not match the +thing being selected — every other in-scope category is named after the +noun whose type is chosen (`minimizer`, `peak`, `background`, +`extinction`, `chart`, `table`, `fitting_mode`). The mismatched name is +the only reason the descriptor is called `calculator_type` rather than +`type`. + +The Python class `Calculation` is renamed to `Calculator`. The +owner-side attribute moves from `experiment.calculation` to +`experiment.calculator`. The single writable selector becomes +`experiment.calculator.type`. The CIF block becomes `_calculator.*`. The +live calculator engine (which today is held on `experiment._calculator` +and rebound by `ExperimentBase._set_calculator_type`) keeps its current +binding — this is a Family-B engine-swap mechanism; only the user-facing +surface and the CIF block name change. + +Affected ADRs: this rename adds a small amendment to +[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +(category and CIF tag both move from `calculation` to `calculator`) and +to [`selector-families.md`](selector-families.md) (the example row for +Family B). + +After all three structural changes the `_fitting.*`, `_rendering.*`, and +`_calculation.*` CIF blocks disappear from saved projects. All three +were either heterogeneous bags (`_fitting`, `_rendering`) or mis-named +singletons (`_calculation`); the new layout has one selector per block, +aligned with the descriptor it persists and the noun it names. + +## Catalog of selectors across the codebase + +After this ADR every selector that presents a writable type surface +follows the **same** `category.type` shape, regardless of mechanism +family. The single in-scope table below covers all eight; the mechanism +column distinguishes what happens behind the setter (A = category +instance swap, B = engine swap, C = sibling activation). Plain enum +descriptors (Family D) keep their existing form and are listed in a +separate, smaller table. + +### In scope — every selector with a writable type surface + +| # | Owner | Today | Proposed Python | CIF today | CIF proposed | Mech | Source | +| --- | ---------- | ---------------------------------------------- | ---------------------------------- | --------------------------------------- | -------------------- | -------------- | ------------------------------------------------- | +| 1 | analysis | `analysis.minimizer_type = 'X'` | `analysis.minimizer.type = 'X'` | `_fitting.minimizer_type` | `_minimizer.type` | A | `analysis/analysis.py:1022` | +| 2 | experiment | `experiment.peak_profile_type = 'X'` | `experiment.peak.type = 'X'` | `_peak.profile_type` | `_peak.type` | A | `experiment/item/base.py:514` | +| 3 | experiment | `experiment.background_type = 'X'` | `experiment.background.type = 'X'` | (none — only `_pd_background.*` loop) | `_background.type` | A | `experiment/item/bragg_pd.py:184` | +| 4 | experiment | `experiment.extinction_type = 'X'` | `experiment.extinction.type = 'X'` | (none — only active class's own fields) | `_extinction.type` | A | `experiment/item/base.py:312` | +| 5 | experiment | `experiment.calculation.calculator_type = 'X'` | `experiment.calculator.type = 'X'` | `_calculation.calculator_type` | `_calculator.type` | B + §8 rename | `experiment/categories/calculation/default.py:50` | +| 6 | project | `project.rendering.chart_engine = 'X'` | `project.chart.type = 'X'` | `_rendering.chart_engine` | `_chart.type` | B + §8 split | `project/categories/rendering/default.py:100` | +| 7 | project | `project.rendering.table_engine = 'X'` | `project.table.type = 'X'` | `_rendering.table_engine` | `_table.type` | B + §8 split | `project/categories/rendering/default.py:109` | +| 8 | analysis | `analysis.fitting_mode_type = 'X'` | `analysis.fitting_mode.type = 'X'` | `_fitting.mode_type` | `_fitting_mode.type` | C + §8 promote | `analysis/analysis.py:960` | + +Mechanism legend (recap): + +- **A (instance swap):** owner's `_swap_` calls + `Factory.create(value)` and rebinds the slot. +- **B (engine swap):** owner's `_swap_` keeps the singleton + category, rebinds the live engine behind it (Plotter, TableRenderer, + calculator backend). +- **C (sibling activation):** owner's `_swap_` records the new + `FitModeEnum` value and updates which sibling categories are visible, + authoritative, and serialised. + +The user-facing Python and CIF columns are uniform across all eight. +Implementers and reviewers can read every row from a single template. + +Notes on the in-scope rows: + +- Rows 1–4 are pure renames + the structural moves in §3 (Python + selector onto the category; CIF tag onto `_.type`). +- Row 5 involves §8c's `Calculation` → `Calculator` rename: the Python + class is renamed, the owner-side attribute moves from + `experiment.calculation` to `experiment.calculator`, the descriptor is + renamed `calculator_type` → `type`, and the CIF block changes from + `_calculation.*` to `_calculator.*`. The setter delegation pattern is + already in place today + ([`calculation/default.py:61`](../../../src/easydiffraction/datablocks/experiment/categories/calculation/default.py)), + so no mechanism change is required. +- Rows 6 and 7 involve §8a's `Rendering` → `Chart` + `Table` split + (Python category restructure, CIF block split). +- Row 8 involves §8b's `FittingMode` promotion (new minimal category on + `Analysis`, CIF block rename). + +### Out of scope — plain enum descriptors (Family D) + +Closed-set string descriptors with a `MembershipValidator`. Selecting a +value records a choice and may trigger bookkeeping, but does not present +the `category.type` writable surface and does not swap a category, +engine, or sibling. + +| # | Python | CIF | Effect | Source | +| --- | ---------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| 1 | `experiment.type.sample_form` | `_expt_type.sample_form` | Powder vs single-crystal; creation-time axis. | `experiment/categories/experiment_type/default.py:104` | +| 2 | `experiment.type.beam_mode` | `_expt_type.beam_mode` | CWL vs TOF; creation-time axis. | `…/experiment_type/default.py:114` | +| 3 | `experiment.type.radiation_probe` | `_expt_type.radiation_probe` | Neutron vs X-ray; creation-time axis. | `…/experiment_type/default.py:124` | +| 4 | `experiment.type.scattering_type` | `_expt_type.scattering_type` | Bragg vs total scattering; creation-time axis. | `…/experiment_type/default.py:134` | +| 5 | `atom_site.adp_type` (per row of `structure.atom_sites`) | `_atom_site.adp_type` | Pick ADP convention (`Biso`/`Uiso`/`Bani`/`Uani`); triggers value conversion and `atom_site_aniso` sibling sync. | `structure/categories/atom_sites/default.py:377` | +| 6 | `extinction.becker-coppens.model` (nested inside in-scope row 4 when active) | `_extinction.model` | Mosaicity distribution (`gauss`/`lorentz`) inside the Becker-Coppens extinction category. | `experiment/categories/extinction/becker_coppens.py:92` | +| 7 | `project.verbosity.fit` | `_verbosity.fit` | Pick fit-output verbosity (`full`/`short`/`silent`). | `project/categories/verbosity/default.py:43` | + +Notes on Family D: + +- Rows 1–4 are creation-time axes governed by + [`immutable-experiment-type.md`](immutable-experiment-type.md); no + user-facing writable setter, listed only for completeness. +- Row 5 (`adp_type`) has Family-C-like side effects per + [`type-neutral-adp-parameters.md`](type-neutral-adp-parameters.md). + Stays. +- Row 6 (`extinction.becker-coppens.model`) is a Family-D enum + **inside** an in-scope Family-A category (extinction). Selecting the + model is local to the Becker-Coppens class; selecting the extinction + class itself goes through the new `experiment.extinction.type` + surface. Both work independently. + +### Categories with a `_type` slot but no observable selector (informational) + +Many categories store a private +`self.__type: str = Factory.default_tag()` but only one concrete type +is registered (`default`), so no public `_type` property, no +`show_supported_*` method, and no observable selector exist. Examples: +`aliases`, `constraints`, `cell`, `space_group`, `atom_sites`, +`atom_site_aniso`, `diffrn`, `linked_crystal`, `linked_phases`, +`excluded_regions`, `data`, `instrument`, `refln`, `sequential_fit`, +`sequential_fit_extract`. + +If a second concrete type is ever registered for one of these, this +ADR's rule applies automatically: the category becomes an in-scope +member and exposes `category.type` plus `category.show_supported()`. + +## Consequences + +### Architecture wins + +- One uniform API surface per category: `type`, `show_supported()`, and + the category's own descriptors. No two-scope split. +- `category.help()` becomes self-contained: it lists every property the + user can read or write _for that category_, including the type + selector. Discoverability is single-step. +- CIF projects shrink by one tag per switchable category plus any + redundant identity-echo fields. The remaining `_.*` block is a + self-describing record of the active backend's state. +- The convention extends naturally to future switchable categories. + Adding emcee, a new background flavor, a new peak profile, etc. costs + one class plus factory registration; no owner-side selector plumbing. + +### Trade-offs + +- Reference-staleness is a real but documented quirk (see §5). The + scientist workflow does not hit it; expert users who store a reference + and then swap have a documented gotcha. +- The migration is cross-cutting. Every test, tutorial, and saved + fixture that touches a switchable selector needs an edit. Order of + magnitude: dozens of test files, every tutorial, every saved + `analysis.cif` / `experiment.cif`. +- Two beta-period CIF formats coexist briefly (consolidation just + shipped). Anyone with projects saved in the post-consolidation format + needs to regenerate; the project's beta posture covers this. + +### ADRs that need to be updated when this ADR is accepted + +- [`switchable-category-api.md`](switchable-category-api.md) — rewrite + the §"Decision" to point at this ADR. The new contract is "the + category exposes `type` (getter+setter) and `show_supported()`; the + owner exposes only the category itself". +- [`selector-families.md`](selector-families.md) — rewrite the + §"Decision" to use the mechanism-vs-surface framing from §6: all three + families (A switchable categories, B backend selectors, C + active-sibling selectors) present the same writable `category.type` + surface; the family classification documents only what the owner's + `_swap_` does behind that surface. Update every "Examples" row + to the proposed `..type` form. +- [`fit-mode-categories.md`](fit-mode-categories.md) — strike the + matching "Deferred Work" entry (this ADR closes the follow-up). + Replace the `analysis.fitting_mode_type` description with + `analysis.fitting_mode.type` and document the new `FittingMode` + category (§8b). +- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + — the §"Owner-level switchable selectors" table becomes obsolete; + remove the "deliberate abstraction" exception. Update every entry to + the `_.type` form. +- [`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) + — append a "Superseded selector layout" note pointing here for the + `_fitting.minimizer_type` → `_minimizer.type` change and the drop of + `_minimizer.optimizer_name` / `_minimizer.method_name`. +- [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) — drop + `_minimizer.optimizer_name` and `_minimizer.method_name` from the + persisted projection (§3 of this ADR). The runtime + `FitResults.optimizer_name` / `method_name` fields are populated on + restore from the active minimizer class's metadata rather than from + CIF. +- [`display-ux.md`](display-ux.md) — replace every reference to + `project.rendering`, `_rendering.chart_engine`, and + `_rendering.table_engine` with the post-§8a shape: + `project.chart.type`, `project.table.type`, CIF blocks `_chart.*` and + `_table.*`. Drop the writable-selector contract that puts chart/table + engines on the `rendering` category; document instead that each + renderer lives on its own category with the canonical `category.type` + surface. +- [`category-owner-sections.md`](category-owner-sections.md) — update + the `ProjectConfig` children list: drop `Rendering`; add `Chart` and + `Table` as siblings. Update the `_rendering.*` CIF block reference to + `_chart.*` and `_table.*`. + +(A grep against `docs/dev/adrs/accepted/` for the renamed Python names +and CIF tags surfaced four additional hits that turned out to be generic +phrasing — "rendering engines", "real calculation engines", +"calculator_support for calculation-engine support", "rendering +preferences" — rather than category-specific references. Those four ADRs +(`factory-contracts.md`, `test-strategy.md`, +`enum-backed-closed-values.md`, `project-facade-and-persistence.md`) are +unaffected and intentionally not listed here. See Reply 2 F4 for the +full grep results.) + +### Issues that this ADR closes + +- [#72 "Warn on All Switchable-Category Type Changes"](../../issues/open.md) + — the warning logic moves into each owner's `_swap_` method; + uniform by construction. +- [#76 "Consistent `_type` suffix in switchable-category API names"](../../issues/open.md) + — superseded; the new convention drops the suffix entirely. + +## Alternatives Considered + +### A. `__class__` mutation on the existing instance + +Replace `self.__class__` with the new concrete class so the user's +reference keeps pointing at "the same object". Rejected: +[`.github/copilot-instructions.md`](../../../../.github/copilot-instructions.md) +→ **Architecture** forbids it ("no monkey-patching or runtime class +mutation"). It would also confuse `isinstance` checks and break +descriptor introspection. + +### B. Single category class with internal mode switching + +One `MinimizerCategory` class with a `type` attribute that reconfigures +internal state. Rejected: conflicts with +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +§8 ("concrete classes carry their own defaults; no mixins"). Loses +per-backend type signatures and `help()` output. + +### C. Proxy / handle layer + +`analysis.minimizer` returns a `SwitchableHandle[MinimizerCategoryBase]` +that wraps the concrete instance and delegates via `__getattr__`. +Rejected: the user wants the _category_ to be the interface, not a +wrapper that looks like one. `isinstance` checks, descriptor +introspection, factory metadata, and `help()` all need bespoke +delegation. The cost is much higher than the back-reference approach for +no extra capability. + +### D. Keep owner-level setters; just deduplicate CIF + +Strip `_fitting.minimizer_type` from CIF while leaving the Python API at +owner level. Rejected: fixes the CIF duplication but leaves the UX split +(the original complaint). Half a solution at the cost of a separate +amendment ADR. + +### E. Hybrid: writable `type` on category + alias on owner + +Keep `._type` as a thin alias that forwards to +`..type` setter, "for backwards compatibility". Rejected: +beta posture explicitly says "no legacy shims, no deprecation warnings". +Dual surfaces double the API and the documentation burden. + +## Example: end state across the project + +The end state of every switchable surface, in one place. All eight +in-scope rows from the catalog appear; the uniform `_.type` rule is +visible at a glance. + +### `project.cif` + +``` +data_project + +_chart.type plotly +_table.type rich +``` + +The `_rendering.*` block is gone; two single-purpose blocks replace it +(§8a). + +### `experiment.cif` (per-experiment block) + +``` +data_hrpt + +_calculator.type cryspy + +_peak.type cwl-pseudo-voigt +_peak.broad_gauss_u 0.0123 +_peak.broad_gauss_v -0.0045 +_peak.broad_gauss_w 0.0212 +_peak.broad_lorentz_x 0.0085 +_peak.broad_lorentz_y 0.0021 + +_background.type chebyshev +loop_ + _pd_background.id + _pd_background.Chebyshev_order + _pd_background.Chebyshev_coef + 1 0 0.42 + 2 1 -0.18 + 3 2 0.05 +``` + +Three things change in this block: + +- `_calculation.calculator_type` becomes `_calculator.type` (the + category is also renamed `Calculation` → `Calculator`; §8c). +- `_peak.profile_type` becomes `_peak.type`; the existing + `_peak.broad_gauss_*` and `_peak.broad_lorentz_*` parameter tags are + unchanged (the names come from + [`src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py`](../../../src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py)). + The CIF value is the **canonical tag** (`cwl-pseudo-voigt` here, since + the example experiment is constant-wavelength); the writable Python + setter `experiment.peak.type` accepts the alias `'pseudo-voigt'` too + and canonicalizes it before persisting (see §4 → "Aliases"). +- `_background.type` is **new** (today the type is implicit in whichever + `_pd_background.*` columns are present); the existing + `_pd_background.Chebyshev_order` / `_pd_background.Chebyshev_coef` + loop tags are unchanged. + +### `analysis.cif` (Bayesian fit) + +``` +data_analysis + +_fitting_mode.type joint + +_minimizer.type 'bumps (dream)' +_minimizer.sampling_steps 3000 +_minimizer.burn_in_steps 600 +_minimizer.thinning_interval 1 +_minimizer.population_size 4 +_minimizer.parallel_workers 0 +_minimizer.initialization_method latin_hypercube +_minimizer.random_seed ? +_minimizer.runtime_seconds 124.7 +_minimizer.acceptance_rate_mean 0.27 +_minimizer.gelman_rubin_max 1.03 +_minimizer.effective_sample_size_min 482 +_minimizer.best_log_posterior -1234.56 +_minimizer.reduced_chi2 1.18 +``` + +The `_fitting.*` block is gone (`_fitting.minimizer_type` → +`_minimizer.type`; `_fitting.mode_type` → `_fitting_mode.type`). + +### `analysis.cif` (deterministic fit) + +``` +data_analysis + +_fitting_mode.type single + +_minimizer.type 'lmfit (leastsq)' +_minimizer.max_iterations 1000 +_minimizer.objective_value 1532.4 +_minimizer.runtime_seconds 12.34 +_minimizer.iterations_performed 87 +_minimizer.exit_reason converged +_minimizer.reduced_chi2 1.42 +``` + +`_minimizer.optimizer_name` and `_minimizer.method_name` are gone — they +were per-engine constants and are derived from `_minimizer.type` at +restore time (§3). + +### Python surface + +```python +# Family A — instance-swap categories +project.analysis.minimizer.show_supported() +project.analysis.minimizer.type = 'bumps (dream)' +project.analysis.minimizer.sampling_steps = 3000 + +project.experiments['hrpt'].peak.show_supported() +project.experiments['hrpt'].peak.type = 'pseudo-voigt' +project.experiments['hrpt'].peak.broad_gauss_u = 0.0123 +project.experiments['hrpt'].peak.broad_lorentz_x = 0.0085 + +project.experiments['hrpt'].background.show_supported() +project.experiments['hrpt'].background.type = 'chebyshev' +project.experiments['hrpt'].background.create(id='1', order=0, coef=0.42) + +# Family B — engine-swap categories (same surface) +project.experiments['hrpt'].calculator.show_supported() +project.experiments['hrpt'].calculator.type = 'cryspy' + +project.chart.show_supported() +project.chart.type = 'plotly' + +project.table.show_supported() +project.table.type = 'rich' + +# Family C — active-sibling selector (same surface) +project.analysis.fitting_mode.show_supported() +project.analysis.fitting_mode.type = 'joint' +``` + +One template, eight selectors, three mechanisms behind the scenes. diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 50a287db9..28859e145 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -13,34 +13,36 @@ folders. ## ADR Index -| Group | Status | Title | Short description | Link | -| -------------------- | ---------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | -| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | -| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | -| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | -| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | -| Analysis and fitting | Suggestion | Parameter-Level Posterior Projection | Narrows the still-open `parameter.posterior` API after analysis-level posterior summaries were accepted. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | -| Analysis and fitting | Suggestion | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](suggestions/undo-fit.md) | -| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | -| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | -| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | -| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | -| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | -| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | -| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | -| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | -| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | -| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | -| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | -| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | -| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | -| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | -| Persistence | Suggestion | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then proposes scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](suggestions/python-cif-category-correspondence.md) | -| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | -| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | -| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | -| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | -| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | -| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | -| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | +| Group | Status | Title | Short description | Link | +| -------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | +| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | +| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | +| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | +| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | +| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | +| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | +| Analysis and fitting | Suggestion | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](suggestions/undo-fit.md) | +| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | +| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | +| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | +| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | +| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | +| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | +| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | +| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | +| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | +| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | +| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | +| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | +| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | +| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | +| Persistence | Suggestion | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then proposes scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](suggestions/python-cif-category-correspondence.md) | +| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | +| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | +| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | +| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | +| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | +| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | +| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | +| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | diff --git a/docs/dev/adrs/suggestions/parameter-posterior-summary.md b/docs/dev/adrs/suggestions/parameter-posterior-summary.md index 36c0b979b..88146ec19 100644 --- a/docs/dev/adrs/suggestions/parameter-posterior-summary.md +++ b/docs/dev/adrs/suggestions/parameter-posterior-summary.md @@ -1,6 +1,14 @@ # ADR: Parameter-Level Posterior Projection -**Status:** Proposed **Date:** 2026-05-13 +**Status:** Superseded by +[`minimizer-category-consolidation`](../accepted/minimizer-category-consolidation.md). +**Date:** 2026-05-13 + +## Superseded + +The accepted minimizer-category consolidation ADR implements +`Parameter.posterior` and persists posterior summary fields on +`_fit_parameter`. This suggestion is kept for historical context only. ## Status Note diff --git a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md b/docs/dev/adrs/suggestions/python-cif-category-correspondence.md index 191f18e59..653d57fdd 100644 --- a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md +++ b/docs/dev/adrs/suggestions/python-cif-category-correspondence.md @@ -16,14 +16,15 @@ saved in: project.cif ``` -Inside that file, generic category names such as `_info.*`, -`_rendering.*`, and `_verbosity.*` are less ambiguous than they would be -in a single monolithic CIF file. This opens the option of a strict +Inside that file, generic category names such as `_info.*`, `_chart.*`, +`_table.*`, and `_verbosity.*` are less ambiguous than they would be in +a single monolithic CIF file. This opens the option of a strict one-to-one correspondence for project-owned singleton categories: ```text project.info.title -> project.cif: _info.title -project.rendering.engine -> project.cif: _rendering.engine +project.chart.type -> project.cif: _chart.type +project.table.type -> project.cif: _table.type project.verbosity.fit -> project.cif: _verbosity.fit ``` @@ -52,37 +53,37 @@ to objects reached from the current `Project` root, for example ## Current Persistence Layout -| Current Python surface | Current saved location | Current CIF block form | Notes | -| ----------------------------------- | ------------------------ | ---------------------- | ----------------------------------------------------------------------------------- | -| `project.info`, `project.rendering` | `project.cif` | bare categories | Project-level singleton config. | -| `project.verbosity` | `project.cif` | bare category | Project-owned fit-output verbosity category backed by `VerbosityEnum`. | -| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. | -| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. | -| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. | -| `project.summary` | `summary.cif` | placeholder text | Summary persistence exists as a file but `summary_to_cif()` is not implemented yet. | +| Current Python surface | Current saved location | Current CIF block form | Notes | +| ------------------------------------------------ | ------------------------ | ---------------------- | ----------------------------------------------------------------------------------- | +| `project.info`, `project.chart`, `project.table` | `project.cif` | bare categories | Project-level singleton config. | +| `project.verbosity` | `project.cif` | bare category | Project-owned fit-output verbosity category backed by `VerbosityEnum`. | +| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. | +| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. | +| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. | +| `project.summary` | `summary.cif` | placeholder text | Summary persistence exists as a file but `summary_to_cif()` is not implemented yet. | ## Current Correspondence ### Project-Level Configuration -| Current Python path | Current CIF path | Match? | Notes | -| -------------------------------- | ------------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| `project.info.name` | `_project.id` | No | Python uses user-facing `name`; CIF uses `id`; category is `info` in Python but `_project` in CIF. | -| `project.info.title` | `_project.title` | Partly | Field name matches, category name does not. | -| `project.info.description` | `_project.description` | Partly | Field name matches, category name does not. | -| `project.info.created` | `_project.created` | Partly | Field name matches, category name does not. | -| `project.info.last_modified` | `_project.last_modified` | Partly | Field name matches, category name does not. | -| `project.info.path` | none | No | Runtime storage path, not a CIF field. | -| `project.rendering.chart_engine` | `_rendering.chart_engine` | Yes | Direct category and field mapping. | -| `project.rendering.table_engine` | `_rendering.table_engine` | Yes | Direct category and field mapping. | -| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. | +| Current Python path | Current CIF path | Match? | Notes | +| ---------------------------- | ------------------------ | ------ | -------------------------------------------------------------------------------------------------- | +| `project.info.name` | `_project.id` | No | Python uses user-facing `name`; CIF uses `id`; category is `info` in Python but `_project` in CIF. | +| `project.info.title` | `_project.title` | Partly | Field name matches, category name does not. | +| `project.info.description` | `_project.description` | Partly | Field name matches, category name does not. | +| `project.info.created` | `_project.created` | Partly | Field name matches, category name does not. | +| `project.info.last_modified` | `_project.last_modified` | Partly | Field name matches, category name does not. | +| `project.info.path` | none | No | Runtime storage path, not a CIF field. | +| `project.chart.type` | `_chart.type` | Yes | Direct category-owned selector mapping. | +| `project.table.type` | `_table.type` | Yes | Direct category-owned selector mapping. | +| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. | ### Analysis Configuration | Current Python path | Current CIF path | Match? | Notes | | ------------------------------------------------- | ---------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -| `analysis.fitting.minimizer_type` | `_fitting.minimizer_type` | Yes | Direct category mapping. | -| `analysis.fitting_mode_type` | `_fitting.mode_type` | No | Public selector is owner-level state serialized into the `_fitting` category. | +| `analysis.minimizer.type` | `_minimizer.type` | Yes | Direct category-owned selector mapping. | +| `analysis.fitting_mode.type` | `_fitting_mode.type` | Yes | Direct category-owned active-sibling selector mapping. | | `analysis.joint_fit[experiment_id].experiment_id` | `_joint_fit.experiment_id` | Yes | Collection key is also stored as a field. | | `analysis.joint_fit[experiment_id].weight` | `_joint_fit.weight` | Yes | Direct field mapping. | | `analysis.sequential_fit.data_dir` | `_sequential_fit.data_dir` | Yes | Direct category mapping. | @@ -100,62 +101,61 @@ to objects reached from the current `Project` root, for example ### Experiment Configuration -| Current Python path | Current CIF path | Match? | Notes | -| --------------------------------------------- | ---------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------ | -| `experiment.type.sample_form` | `_expt_type.sample_form` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | -| `experiment.type.beam_mode` | `_expt_type.beam_mode` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | -| `experiment.type.radiation_probe` | `_expt_type.radiation_probe` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | -| `experiment.type.scattering_type` | `_expt_type.scattering_type` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | -| `experiment.calculation.calculator_type` | `_calculation.calculator_type` | Yes | Direct category mapping. | -| `experiment.diffrn.ambient_temperature` | `_diffrn.ambient_temperature` | Yes | Direct category mapping. | -| `experiment.diffrn.ambient_pressure` | `_diffrn.ambient_pressure` | Yes | Direct category mapping. | -| `experiment.diffrn.ambient_magnetic_field` | `_diffrn.ambient_magnetic_field` | Yes | Direct category mapping. | -| `experiment.diffrn.ambient_electric_field` | `_diffrn.ambient_electric_field` | Yes | Direct category mapping. | -| `experiment.instrument.setup_wavelength` | `_instr.wavelength` | Partly | Python name exposes setup role; CIF tag uses compact instrument name. | -| `experiment.instrument.calib_twotheta_offset` | `_instr.2theta_offset` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | -| `experiment.instrument.setup_twotheta_bank` | `_instr.2theta_bank` | Partly | Python name exposes setup role; CIF tag uses compact instrument name. | -| `experiment.instrument.calib_d_to_tof_offset` | `_instr.d_to_tof_offset` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | -| `experiment.instrument.calib_d_to_tof_linear` | `_instr.d_to_tof_linear` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | -| `experiment.instrument.calib_d_to_tof_quad` | `_instr.d_to_tof_quad` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | -| `experiment.instrument.calib_d_to_tof_recip` | `_instr.d_to_tof_recip` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | -| `experiment.peak.profile_type` | `_peak.profile_type` | Yes | The active peak category stores its own type tag. | -| `experiment.peak_profile_type` | `_peak.profile_type` | No | Public selector is an owner-level convenience alias. | -| `experiment.peak.broad_gauss_u` | `_peak.broad_gauss_u` | Yes | CWL peak field. | -| `experiment.peak.broad_gauss_v` | `_peak.broad_gauss_v` | Yes | CWL peak field. | -| `experiment.peak.broad_gauss_w` | `_peak.broad_gauss_w` | Yes | CWL peak field. | -| `experiment.peak.broad_lorentz_x` | `_peak.broad_lorentz_x` | Yes | CWL peak field. | -| `experiment.peak.broad_lorentz_y` | `_peak.broad_lorentz_y` | Yes | CWL peak field. | -| `experiment.peak.asym_empir_1..4` | `_peak.asym_empir_1..4` | Yes | CWL peak field group. | -| `experiment.peak.asym_fcj_1..2` | `_peak.asym_fcj_1..2` | Yes | CWL peak field group. | -| `experiment.peak.broad_gauss_sigma_0..2` | `_peak.gauss_sigma_0..2` | Partly | Python prefixes the family with `broad_`; CIF tags omit that grouping prefix. | -| `experiment.peak.broad_lorentz_gamma_0..2` | `_peak.lorentz_gamma_0..2` | Partly | Python prefixes the family with `broad_`; CIF tags omit that grouping prefix. | -| `experiment.peak.exp_rise_alpha_0..1` | `_peak.rise_alpha_0..1` | Partly | Python prefixes the family with `exp_`; CIF tags omit that grouping prefix. | -| `experiment.peak.exp_decay_beta_0..1` | `_peak.decay_beta_0..1` | Partly | Python prefixes the family with `exp_`; CIF tags omit that grouping prefix. | -| `experiment.peak.dexp_*` | `_peak.dexp_*` | Yes | TOF double-exponential peak field group. | -| `experiment.peak.damp_q` | `_peak.damp_q` | Yes | Total-scattering peak field. | -| `experiment.peak.broad_q` | `_peak.broad_q` | Yes | Total-scattering peak field. | -| `experiment.peak.cutoff_q` | `_peak.cutoff_q` | Yes | Total-scattering peak field. | -| `experiment.peak.sharp_delta_1` | `_peak.sharp_delta_1` | Yes | Total-scattering peak field. | -| `experiment.peak.sharp_delta_2` | `_peak.sharp_delta_2` | Yes | Total-scattering peak field. | -| `experiment.peak.damp_particle_diameter` | `_peak.damp_particle_diameter` | Yes | Total-scattering peak field. | -| `experiment.background[id].id` line segment | `_pd_background.id` | Partly | Python category is `background`; CIF uses powder-background category. | -| `experiment.background[id].x` line segment | `_pd_background.line_segment_X` or `_pd_background_line_segment_X` | Partly | Python uses compact `x`; CIF tag encodes powder-background line-segment meaning. | -| `experiment.background[id].y` line segment | `_pd_background.line_segment_intensity` or `_pd_background_line_segment_intensity` | Partly | Python uses compact `y`; CIF tag encodes powder-background line-segment meaning. | -| `experiment.background[id].id` Chebyshev | `_pd_background.id` | Partly | Python category is `background`; CIF uses powder-background category. | -| `experiment.background[id].order` Chebyshev | `_pd_background.Chebyshev_order` | Partly | CIF tag encodes polynomial type and uses CIF-style capitalization. | -| `experiment.background[id].coef` Chebyshev | `_pd_background.Chebyshev_coef` | Partly | CIF tag encodes polynomial type and uses CIF-style capitalization. | -| `experiment.background_type` | implied by active background category | No | There is no standalone selector tag. | -| `experiment.extinction.model` | `_extinction.model` | Yes | Direct category mapping. | -| `experiment.extinction.mosaicity` | `_extinction.mosaicity` | Yes | Direct category mapping. | -| `experiment.extinction.radius` | `_extinction.radius` | Yes | Direct category mapping. | -| `experiment.extinction_type` | `_extinction.model` | Partly | Public selector chooses the category; persisted model tag lives inside the category. | -| `experiment.linked_phases[id].id` | `_pd_phase_block.id` | Partly | Python name is user-facing; CIF tag follows powder phase-block convention. | -| `experiment.linked_phases[id].scale` | `_pd_phase_block.scale` | Partly | Python name is user-facing; CIF tag follows powder phase-block convention. | -| `experiment.linked_crystal.id` | `_sc_crystal_block.id` | Partly | Python name is user-facing; CIF tag follows single-crystal block convention. | -| `experiment.linked_crystal.scale` | `_sc_crystal_block.scale` | Partly | Python name is user-facing; CIF tag follows single-crystal block convention. | -| `experiment.excluded_regions[id].id` | `_excluded_region.id` | Partly | Python collection is plural; CIF row category is singular. | -| `experiment.excluded_regions[id].start` | `_excluded_region.start` | Partly | Python collection is plural; CIF row category is singular. | -| `experiment.excluded_regions[id].end` | `_excluded_region.end` | Partly | Python collection is plural; CIF row category is singular. | +| Current Python path | Current CIF path | Match? | Notes | +| --------------------------------------------- | ---------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------- | +| `experiment.type.sample_form` | `_expt_type.sample_form` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | +| `experiment.type.beam_mode` | `_expt_type.beam_mode` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | +| `experiment.type.radiation_probe` | `_expt_type.radiation_probe` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | +| `experiment.type.scattering_type` | `_expt_type.scattering_type` | Partly | Python uses the user-facing word `type`; CIF uses abbreviated `_expt_type`. | +| `experiment.calculator.type` | `_calculator.type` | Yes | Direct category-owned backend selector mapping. | +| `experiment.diffrn.ambient_temperature` | `_diffrn.ambient_temperature` | Yes | Direct category mapping. | +| `experiment.diffrn.ambient_pressure` | `_diffrn.ambient_pressure` | Yes | Direct category mapping. | +| `experiment.diffrn.ambient_magnetic_field` | `_diffrn.ambient_magnetic_field` | Yes | Direct category mapping. | +| `experiment.diffrn.ambient_electric_field` | `_diffrn.ambient_electric_field` | Yes | Direct category mapping. | +| `experiment.instrument.setup_wavelength` | `_instr.wavelength` | Partly | Python name exposes setup role; CIF tag uses compact instrument name. | +| `experiment.instrument.calib_twotheta_offset` | `_instr.2theta_offset` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | +| `experiment.instrument.setup_twotheta_bank` | `_instr.2theta_bank` | Partly | Python name exposes setup role; CIF tag uses compact instrument name. | +| `experiment.instrument.calib_d_to_tof_offset` | `_instr.d_to_tof_offset` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | +| `experiment.instrument.calib_d_to_tof_linear` | `_instr.d_to_tof_linear` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | +| `experiment.instrument.calib_d_to_tof_quad` | `_instr.d_to_tof_quad` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | +| `experiment.instrument.calib_d_to_tof_recip` | `_instr.d_to_tof_recip` | Partly | Python name exposes calibration role; CIF tag uses compact instrument name. | +| `experiment.peak.type` | `_peak.type` | Yes | Direct category-owned selector mapping. | +| `experiment.peak.broad_gauss_u` | `_peak.broad_gauss_u` | Yes | CWL peak field. | +| `experiment.peak.broad_gauss_v` | `_peak.broad_gauss_v` | Yes | CWL peak field. | +| `experiment.peak.broad_gauss_w` | `_peak.broad_gauss_w` | Yes | CWL peak field. | +| `experiment.peak.broad_lorentz_x` | `_peak.broad_lorentz_x` | Yes | CWL peak field. | +| `experiment.peak.broad_lorentz_y` | `_peak.broad_lorentz_y` | Yes | CWL peak field. | +| `experiment.peak.asym_empir_1..4` | `_peak.asym_empir_1..4` | Yes | CWL peak field group. | +| `experiment.peak.asym_fcj_1..2` | `_peak.asym_fcj_1..2` | Yes | CWL peak field group. | +| `experiment.peak.broad_gauss_sigma_0..2` | `_peak.gauss_sigma_0..2` | Partly | Python prefixes the family with `broad_`; CIF tags omit that grouping prefix. | +| `experiment.peak.broad_lorentz_gamma_0..2` | `_peak.lorentz_gamma_0..2` | Partly | Python prefixes the family with `broad_`; CIF tags omit that grouping prefix. | +| `experiment.peak.exp_rise_alpha_0..1` | `_peak.rise_alpha_0..1` | Partly | Python prefixes the family with `exp_`; CIF tags omit that grouping prefix. | +| `experiment.peak.exp_decay_beta_0..1` | `_peak.decay_beta_0..1` | Partly | Python prefixes the family with `exp_`; CIF tags omit that grouping prefix. | +| `experiment.peak.dexp_*` | `_peak.dexp_*` | Yes | TOF double-exponential peak field group. | +| `experiment.peak.damp_q` | `_peak.damp_q` | Yes | Total-scattering peak field. | +| `experiment.peak.broad_q` | `_peak.broad_q` | Yes | Total-scattering peak field. | +| `experiment.peak.cutoff_q` | `_peak.cutoff_q` | Yes | Total-scattering peak field. | +| `experiment.peak.sharp_delta_1` | `_peak.sharp_delta_1` | Yes | Total-scattering peak field. | +| `experiment.peak.sharp_delta_2` | `_peak.sharp_delta_2` | Yes | Total-scattering peak field. | +| `experiment.peak.damp_particle_diameter` | `_peak.damp_particle_diameter` | Yes | Total-scattering peak field. | +| `experiment.background[id].id` line segment | `_pd_background.id` | Partly | Python category is `background`; CIF uses powder-background category. | +| `experiment.background[id].x` line segment | `_pd_background.line_segment_X` or `_pd_background_line_segment_X` | Partly | Python uses compact `x`; CIF tag encodes powder-background line-segment meaning. | +| `experiment.background[id].y` line segment | `_pd_background.line_segment_intensity` or `_pd_background_line_segment_intensity` | Partly | Python uses compact `y`; CIF tag encodes powder-background line-segment meaning. | +| `experiment.background[id].id` Chebyshev | `_pd_background.id` | Partly | Python category is `background`; CIF uses powder-background category. | +| `experiment.background[id].order` Chebyshev | `_pd_background.Chebyshev_order` | Partly | CIF tag encodes polynomial type and uses CIF-style capitalization. | +| `experiment.background[id].coef` Chebyshev | `_pd_background.Chebyshev_coef` | Partly | CIF tag encodes polynomial type and uses CIF-style capitalization. | +| `experiment.background.type` | `_background.type` | Yes | Direct collection-level category-owned selector mapping. | +| `experiment.extinction.type` | `_extinction.type` | Yes | Direct category-owned selector mapping. | +| `experiment.extinction.model` | `_extinction.model` | Yes | Direct category mapping. | +| `experiment.extinction.mosaicity` | `_extinction.mosaicity` | Yes | Direct category mapping. | +| `experiment.extinction.radius` | `_extinction.radius` | Yes | Direct category mapping. | +| `experiment.linked_phases[id].id` | `_pd_phase_block.id` | Partly | Python name is user-facing; CIF tag follows powder phase-block convention. | +| `experiment.linked_phases[id].scale` | `_pd_phase_block.scale` | Partly | Python name is user-facing; CIF tag follows powder phase-block convention. | +| `experiment.linked_crystal.id` | `_sc_crystal_block.id` | Partly | Python name is user-facing; CIF tag follows single-crystal block convention. | +| `experiment.linked_crystal.scale` | `_sc_crystal_block.scale` | Partly | Python name is user-facing; CIF tag follows single-crystal block convention. | +| `experiment.excluded_regions[id].id` | `_excluded_region.id` | Partly | Python collection is plural; CIF row category is singular. | +| `experiment.excluded_regions[id].start` | `_excluded_region.start` | Partly | Python collection is plural; CIF row category is singular. | +| `experiment.excluded_regions[id].end` | `_excluded_region.end` | Partly | Python collection is plural; CIF row category is singular. | ### Experiment Data And Calculated Results @@ -232,23 +232,22 @@ project.info. -> project.cif: _project. ``` Future one-to-one correspondence work may still discuss whether the -public identity field should be `name` or `id`, whether verbosity should -gain additional coverage-specific fields, and whether rendering should -keep separate chart and table engine fields. +public identity field should be `name` or `id`, and whether verbosity +should gain additional coverage-specific fields. Possible strict-correspondence target if a future ADR explicitly changes the accepted `_project.*` baseline: -| Python path | Target CIF path | Current state | -| -------------------------------- | ------------------------- | ------------------------------------------------ | -| `project.info.name` | `_info.name` | Currently `_project.id`. | -| `project.info.title` | `_info.title` | Currently `_project.title`. | -| `project.info.description` | `_info.description` | Currently `_project.description`. | -| `project.info.created` | `_info.created` | Currently `_project.created`. | -| `project.info.last_modified` | `_info.last_modified` | Currently `_project.last_modified`. | -| `project.rendering.chart_engine` | `_rendering.chart_engine` | Already matches. | -| `project.rendering.table_engine` | `_rendering.table_engine` | Already matches. | -| `project.verbosity.fit` | `_verbosity.fit` | Implemented direct fit-output verbosity mapping. | +| Python path | Target CIF path | Current state | +| ---------------------------- | --------------------- | ------------------------------------------------ | +| `project.info.name` | `_info.name` | Currently `_project.id`. | +| `project.info.title` | `_info.title` | Currently `_project.title`. | +| `project.info.description` | `_info.description` | Currently `_project.description`. | +| `project.info.created` | `_info.created` | Currently `_project.created`. | +| `project.info.last_modified` | `_info.last_modified` | Currently `_project.last_modified`. | +| `project.chart.type` | `_chart.type` | Already matches. | +| `project.table.type` | `_table.type` | Already matches. | +| `project.verbosity.fit` | `_verbosity.fit` | Implemented direct fit-output verbosity mapping. | Alternative target if the project identity field should be called `id` rather than `name`: @@ -300,10 +299,14 @@ compatibility with scientific conventions. ### Some Python Names Are Deliberate Abstractions -Type-neutral ADP parameters, owner-level switchable selectors, -active-sibling selectors, and analysis-friendly data names intentionally -do not mirror individual CIF fields. These should remain exceptions -unless a separate ADR changes the underlying API pattern. +Type-neutral ADP parameters and analysis-friendly data names +intentionally do not mirror individual CIF fields. Switchable selectors +are no longer an exception: the accepted +[`switchable-category-owned-selectors.md`](../accepted/switchable-category-owned-selectors.md) +decision uses `category.type` in Python and `_.type` in CIF across +switchable-category, backend, and active-sibling selector families. +These should remain exceptions unless a separate ADR changes the +underlying API pattern. ## Consequences @@ -322,17 +325,16 @@ unless a separate ADR changes the underlying API pattern. - Persisted verbosity is now a category object. The initial field is `project.verbosity.fit`, leaving room for future coverage-specific verbosity fields. -- Collapsing rendering to `project.rendering.engine` would simplify the - API, but only if chart and table renderers are intended to share one - backend choice. +- Chart and table renderers are separate selector categories + (`project.chart.type`, `project.table.type`), so a future collapsed + renderer setting would need a separate ADR. ## Open Questions - Should the project identity remain `project.info.name`, or should it become `project.info.id` to mirror the saved identifier field? -- Should `project.rendering.chart_engine` and - `project.rendering.table_engine` remain separate, or should the public - API and CIF collapse to one `engine` field? +- Should `project.chart.type` and `project.table.type` remain separate, + or should the public API and CIF collapse to one renderer field? - Should `project.verbosity = 'short'` remain as a convenience alias for `project.verbosity.fit = 'short'`, or should strict correspondence remove the alias? diff --git a/docs/dev/issues/closed.md b/docs/dev/issues/closed.md index c2e60653a..4b46b130e 100644 --- a/docs/dev/issues/closed.md +++ b/docs/dev/issues/closed.md @@ -17,6 +17,26 @@ convention in --- +## 72. Warn on All Switchable-Category Type Changes + +Closed by +[`switchable-category-owned-selectors.md`](../adrs/accepted/switchable-category-owned-selectors.md). +Type-change warnings now run through owner `_swap_` hooks, so +every category-owned selector assignment has a uniform owner-mediated +place to warn about values that will be discarded. + +--- + +## 76. Consistent `_type` Suffix in Switchable-Category API Names + +Closed by +[`switchable-category-owned-selectors.md`](../adrs/accepted/switchable-category-owned-selectors.md). +The public suffix is dropped: owners expose categories, categories +expose a uniform `type` property plus `show_supported()`, and CIF uses +one `_.type` selector tag per category. + +--- + ## Restore Minimiser Variant Support Used thin subclasses (approach A) to restore lmfit algorithm variants. diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index 04d97ab85..46e85a912 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -1319,19 +1319,6 @@ order explicit and helps catch priority conflicts. --- -## 72. 🟡 Warn on All Switchable-Category Type Changes - -**Type:** UX / Consistency - -Switching `background_type` already warns: "Switching background type -discards 1 existing background point(s)." The same warning pattern -should apply to all other switchable types (`peak_profile_type`, -`data_type`, etc.) so users know their values will be lost. - -**Depends on:** nothing. - ---- - ## 73. 🟢 Unify Setter Parameter Naming Convention **Type:** Code style @@ -1383,20 +1370,6 @@ calculators are installed. --- -## 76. 🟡 Consistent `_type` Suffix in Switchable-Category API Names - -**Type:** Naming / Consistency - -The switchable-category naming convention prescribes `_type` -(getter/setter) and `show_supported__types()`. But some names -deviate: e.g. `show_minimizer_types()` instead of -`show_supported_minimizer_types()`, and `minimizer_type` instead of -`minimizer_type`. Audit and align all switchable-category APIs. - -**Depends on:** nothing. - ---- - ## 79. 🟢 Verify Completeness of Analysis CIF Serialisation **Type:** Correctness @@ -1695,6 +1668,120 @@ sampler progress displays — any fix should keep their visuals consistent --- +## 100. 🟢 Collapse Duplicate Predictive-Cache-Key Helpers + +**Type:** Refactor / drift risk **Source:** Review 8 finding F1. +**Recommended:** fold into the emcee-minimizer plan while the +surrounding code is being touched. + +`Analysis._predictive_cache_key` +([analysis.py:478-487](../../../src/easydiffraction/analysis/analysis.py)) +and `Plotter._posterior_predictive_key` +([plotting.py:3795-3804](../../../src/easydiffraction/display/plotting.py)) +both return `f'{name}:{x_axis_name}:{suffix}'`. The strings are +identical today; a future refactor that changes one will silently break +lookup against the other. + +**Fix:** collapse to a single helper — either move the canonical helper +to a shared module (e.g. `analysis/fit_helpers/bayesian.py`), or have +`Analysis._store_posterior_predictive_projection` and +`_restored_predictive_summaries` reuse +`Plotter._posterior_predictive_key` from `project.rendering.plotter` +(already accessed nearby). + +**Depends on:** nothing. + +--- + +## 101. 🟢 Remove Dead Branch in `_fit_state_categories` + +**Type:** Dead code **Source:** Review 8 finding F4. **Recommended:** +fold into the emcee-minimizer plan. + +`Analysis._fit_state_categories` +([analysis.py:1135-1148](../../../src/easydiffraction/analysis/analysis.py)) +has +`if result_kind is FitResultKindEnum.DETERMINISTIC: return categories` +followed by `return categories`. Both branches return the same list +since P1.10 absorbed Bayesian-only categories. + +**Fix:** simplify to an unconditional `return categories`. Keep the +preceding `try/except` for its warning side-effect; extract it so the +function body reads cleanly. If a future Bayesian-only category list is +expected, add a TODO instead. + +**Depends on:** nothing. + +--- + +## 102. 🟢 Drop Compute-and-Ignore `result_kind` Validation in CIF Restore + +**Type:** Dead code / clarity **Source:** Review 8 finding F7. +**Recommended:** fold into the emcee-minimizer plan. + +`_restore_persisted_fit_state` +([serialize.py:595-611](../../../src/easydiffraction/io/cif/serialize.py)) +calls `FitResultKindEnum(result_kind_value)` purely for the warning side +effect; the result is discarded. After P1.10 absorbed the +Bayesian-specific categories there is nothing else to do per +`result_kind`. + +**Fix:** replace with a validator helper that takes a string and logs +the warning, or move the warning into `fit_result.result_kind` setter so +invalid values are caught on read. Either removes the "compute and +ignore" pattern. + +**Depends on:** nothing. + +--- + +## 103. 🟢 Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative + +**Type:** Refactor / discoverability **Source:** Review 8 finding F10. +**Recommended:** fold into the emcee-minimizer plan (it adds +`proposal_moves` which is also engine-level). + +`Analysis._sync_engine_from_minimizer_category` +([analysis.py:1077-1089](../../../src/easydiffraction/analysis/analysis.py)) +hardcodes `if key == 'random_seed': continue` to keep call-time seed +threading via `_resolved_fit_random_seed`. A second ambient key joining +it (emcee `proposal_moves`) will need the same treatment. + +**Fix:** declare +`_engine_sync_skip_keys: ClassVar[frozenset[str]] = frozenset({'random_seed'})` +on `MinimizerCategoryBase` (or per family) and filter against it. Adds +declarative, growing coverage. + +**Depends on:** nothing. + +--- + +## 104. 🟢 Tighten `FitParameterItem.posterior_summary` NaN Behaviour + +**Type:** Robustness / partial-data edge case **Source:** Review 8 +finding F9. + +`FitParameterItem.has_posterior_summary` returns `True` if any posterior +field is set, and `posterior_summary` then builds a +`PosteriorParameterSummary` whose missing floats become `NaN`. A +hand-edited or partially-written CIF row with only +`posterior_gelman_rubin = 1.02` and the rest unset produces a summary +whose `median`, `standard_deviation`, and both interval bounds are +`NaN`. Downstream plotting and the `display.fit_results` table render +NaN intervals — harder to debug than a clean "no posterior" outcome. + +The deterministic-fit case is fine: deterministic fits set all required +fields to `None`, so `has_posterior_summary()` returns `False`. + +**Fix:** tighten `has_posterior_summary` to require the core stats (at +least `posterior_median` and one interval bound) before emitting a +summary, or split the dataclass into required-statistics and +optional-diagnostics components. + +**Depends on:** nothing. + +--- + ## Summary | # | Issue | Severity | Type | @@ -1763,11 +1850,9 @@ sampler progress displays — any fix should keep their visuals consistent | 69 | Shorter public API names via `__init__` | 🟢 Low | API ergonomics | | 70 | Standardise class member ordering + headers | 🟡 Med | Code style | | 71 | `_update_priority` reference table | 🟢 Low | Documentation | -| 72 | Warn on all switchable-category type changes | 🟡 Med | UX | | 73 | Unify setter parameter naming | 🟢 Low | Code style | | 74 | Sync property type hints + custom lint rules | 🟡 Med | Tooling | | 75 | `show_supported_calculators()` on Analysis | 🟢 Low | API completeness | -| 76 | Consistent `_type` suffix in switchable APIs | 🟡 Med | Naming | | 79 | Verify analysis CIF serialisation completeness | 🟢 Low | Correctness | | 80 | Resolve `Any` vs `object` annotation policy | 🟢 Low | Code style | | 81 | Enforce docstrings on all public methods | 🟡 Med | Code quality | diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index 677f5851d..a29064bdb 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -24,54 +24,6 @@ │ │ │ │ └── 🏷️ class Aliases │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class AliasesFactory -│ │ ├── 📁 bayesian_convergence -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class BayesianConvergence -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianConvergenceFactory -│ │ ├── 📁 bayesian_distribution_caches -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ ├── 🏷️ class BayesianDistributionCacheItem -│ │ │ │ └── 🏷️ class BayesianDistributionCaches -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianDistributionCachesFactory -│ │ ├── 📁 bayesian_pair_caches -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ ├── 🏷️ class BayesianPairCachePaths -│ │ │ │ ├── 🏷️ class BayesianPairCacheItem -│ │ │ │ └── 🏷️ class BayesianPairCaches -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianPairCachesFactory -│ │ ├── 📁 bayesian_parameter_posteriors -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ ├── 🏷️ class BayesianParameterPosteriorItem -│ │ │ │ └── 🏷️ class BayesianParameterPosteriors -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianParameterPosteriorsFactory -│ │ ├── 📁 bayesian_predictive_datasets -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ ├── 🏷️ class BayesianPredictiveDatasetPaths -│ │ │ │ ├── 🏷️ class BayesianPredictiveDatasetItem -│ │ │ │ └── 🏷️ class BayesianPredictiveDatasets -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianPredictiveDatasetsFactory -│ │ ├── 📁 bayesian_result -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class BayesianResult -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianResultFactory -│ │ ├── 📁 bayesian_sampler -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class BayesianSampler -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class BayesianSamplerFactory │ │ ├── 📁 constraints │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -79,12 +31,6 @@ │ │ │ │ └── 🏷️ class Constraints │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class ConstraintsFactory -│ │ ├── 📁 deterministic_result -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class DeterministicResult -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class DeterministicResultFactory │ │ ├── 📁 fit_parameter_correlations │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -105,13 +51,12 @@ │ │ │ │ └── 🏷️ class FitResult │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class FitResultFactory -│ │ ├── 📁 fit_state -│ │ ├── 📁 fitting +│ │ ├── 📁 fitting_mode │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class Fitting +│ │ │ │ └── 🏷️ class FittingMode │ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class FittingFactory +│ │ │ └── 🏷️ class FittingModeFactory │ │ ├── 📁 joint_fit │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -119,6 +64,34 @@ │ │ │ │ └── 🏷️ class JointFitCollection │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class JointFitFactory +│ │ ├── 📁 minimizer +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ └── 🏷️ class MinimizerCategoryBase +│ │ │ ├── 📄 bayesian_base.py +│ │ │ │ └── 🏷️ class BayesianMinimizerBase +│ │ │ ├── 📄 bumps.py +│ │ │ │ └── 🏷️ class BumpsMinimizer +│ │ │ ├── 📄 bumps_amoeba.py +│ │ │ │ └── 🏷️ class BumpsAmoebaMinimizer +│ │ │ ├── 📄 bumps_de.py +│ │ │ │ └── 🏷️ class BumpsDeMinimizer +│ │ │ ├── 📄 bumps_dream.py +│ │ │ │ └── 🏷️ class BumpsDreamMinimizer +│ │ │ ├── 📄 bumps_lm.py +│ │ │ │ └── 🏷️ class BumpsLmMinimizer +│ │ │ ├── 📄 dfols.py +│ │ │ │ └── 🏷️ class DfolsMinimizer +│ │ │ ├── 📄 factory.py +│ │ │ │ └── 🏷️ class MinimizerCategoryFactory +│ │ │ ├── 📄 lmfit.py +│ │ │ │ └── 🏷️ class LmfitMinimizer +│ │ │ ├── 📄 lmfit_least_squares.py +│ │ │ │ └── 🏷️ class LmfitLeastSquaresMinimizer +│ │ │ ├── 📄 lmfit_leastsq.py +│ │ │ │ └── 🏷️ class LmfitLeastsqMinimizer +│ │ │ └── 📄 lsq_base.py +│ │ │ └── 🏷️ class LeastSquaresMinimizerBase │ │ ├── 📁 sequential_fit │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -136,7 +109,6 @@ │ ├── 📁 fit_helpers │ │ ├── 📄 __init__.py │ │ ├── 📄 bayesian.py -│ │ │ ├── 🏷️ class PosteriorParameterSummary │ │ │ ├── 🏷️ class PosteriorPredictiveSummary │ │ │ ├── 🏷️ class PosteriorSamples │ │ │ └── 🏷️ class BayesianFitResults @@ -170,6 +142,7 @@ │ │ │ └── 🏷️ class DfolsMinimizer │ │ ├── 📄 enums.py │ │ │ ├── 🏷️ class MinimizerTypeEnum +│ │ │ ├── 🏷️ class InitializationMethodEnum │ │ │ └── 🏷️ class DreamPopulationInitializationEnum │ │ ├── 📄 factory.py │ │ │ └── 🏷️ class MinimizerFactory @@ -222,9 +195,13 @@ │ │ ├── 🏷️ class TypeInfo │ │ ├── 🏷️ class Compatibility │ │ └── 🏷️ class CalculatorSupport +│ ├── 📄 posterior.py +│ │ └── 🏷️ class PosteriorParameterSummary │ ├── 📄 singleton.py │ │ ├── 🏷️ class SingletonBase │ │ └── 🏷️ class ConstraintsHandler +│ ├── 📄 switchable.py +│ │ └── 🏷️ class SwitchableCategoryBase │ ├── 📄 validation.py │ │ ├── 🏷️ class DataTypeHints │ │ ├── 🏷️ class DataTypes @@ -269,12 +246,12 @@ │ │ │ │ └── 📄 line_segment.py │ │ │ │ ├── 🏷️ class LineSegment │ │ │ │ └── 🏷️ class LineSegmentBackground -│ │ │ ├── 📁 calculation +│ │ │ ├── 📁 calculator │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py -│ │ │ │ │ └── 🏷️ class Calculation +│ │ │ │ │ └── 🏷️ class Calculator │ │ │ │ └── 📄 factory.py -│ │ │ │ └── 🏷️ class CalculationFactory +│ │ │ │ └── 🏷️ class CalculatorCategoryFactory │ │ │ ├── 📁 data │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 bragg_pd.py @@ -313,6 +290,8 @@ │ │ │ │ └── 🏷️ class ExperimentTypeFactory │ │ │ ├── 📁 extinction │ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class ExtinctionBase │ │ │ │ ├── 📄 becker_coppens.py │ │ │ │ │ └── 🏷️ class BeckerCoppensExtinction │ │ │ │ └── 📄 factory.py @@ -511,6 +490,12 @@ │ └── 📄 results_sidecar.py ├── 📁 project │ ├── 📁 categories +│ │ ├── 📁 chart +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class Chart +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class ChartFactory │ │ ├── 📁 info │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -518,11 +503,12 @@ │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class ProjectInfoFactory │ │ ├── 📁 rendering +│ │ ├── 📁 table │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class Rendering +│ │ │ │ └── 🏷️ class Table │ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class RenderingFactory +│ │ │ └── 🏷️ class TableFactory │ │ ├── 📁 verbosity │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index 367a36a51..dbe4e8f6d 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -15,42 +15,10 @@ │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_convergence -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_distribution_caches -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_pair_caches -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_parameter_posteriors -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_predictive_datasets -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_result -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py -│ │ ├── 📁 bayesian_sampler -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py │ │ ├── 📁 constraints │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 deterministic_result -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py │ │ ├── 📁 fit_parameter_correlations │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -63,8 +31,7 @@ │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 fit_state -│ │ ├── 📁 fitting +│ │ ├── 📁 fitting_mode │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py @@ -72,6 +39,21 @@ │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py +│ │ ├── 📁 minimizer +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ ├── 📄 bayesian_base.py +│ │ │ ├── 📄 bumps.py +│ │ │ ├── 📄 bumps_amoeba.py +│ │ │ ├── 📄 bumps_de.py +│ │ │ ├── 📄 bumps_dream.py +│ │ │ ├── 📄 bumps_lm.py +│ │ │ ├── 📄 dfols.py +│ │ │ ├── 📄 factory.py +│ │ │ ├── 📄 lmfit.py +│ │ │ ├── 📄 lmfit_least_squares.py +│ │ │ ├── 📄 lmfit_leastsq.py +│ │ │ └── 📄 lsq_base.py │ │ ├── 📁 sequential_fit │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -117,7 +99,9 @@ │ ├── 📄 guard.py │ ├── 📄 identity.py │ ├── 📄 metadata.py +│ ├── 📄 posterior.py │ ├── 📄 singleton.py +│ ├── 📄 switchable.py │ ├── 📄 validation.py │ └── 📄 variable.py ├── 📁 crystallography @@ -134,7 +118,7 @@ │ │ │ │ ├── 📄 enums.py │ │ │ │ ├── 📄 factory.py │ │ │ │ └── 📄 line_segment.py -│ │ │ ├── 📁 calculation +│ │ │ ├── 📁 calculator │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py │ │ │ │ └── 📄 factory.py @@ -157,6 +141,7 @@ │ │ │ │ └── 📄 factory.py │ │ │ ├── 📁 extinction │ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py │ │ │ │ ├── 📄 becker_coppens.py │ │ │ │ └── 📄 factory.py │ │ │ ├── 📁 instrument @@ -254,11 +239,16 @@ │ └── 📄 results_sidecar.py ├── 📁 project │ ├── 📁 categories +│ │ ├── 📁 chart +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py │ │ ├── 📁 info │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py │ │ ├── 📁 rendering +│ │ ├── 📁 table │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py diff --git a/docs/dev/plans/emcee-minimizer.md b/docs/dev/plans/emcee-minimizer.md new file mode 100644 index 000000000..b8f056a2d --- /dev/null +++ b/docs/dev/plans/emcee-minimizer.md @@ -0,0 +1,285 @@ +# Plan: Emcee Minimizer + +> This plan follows +> [`.github/copilot-instructions.md`](../../../.github/copilot-instructions.md). +> No deliberate exceptions. + +## Prerequisite + +This plan depends on the +[`minimizer-category-consolidation`](../adrs/accepted/minimizer-category-consolidation.md) +ADR and the +[`switchable-category-owned-selectors`](../adrs/accepted/switchable-category-owned-selectors.md) +ADR. Both are accepted and the implementing work is merged; the +`Analysis.minimizer` + `Analysis.minimizer.type` surface emcee builds on +is in place. + +## ADR + +Implements the emcee follow-on described in §1, §5, §6 and §9 of +[`docs/dev/adrs/accepted/minimizer-category-consolidation.md`](../adrs/accepted/minimizer-category-consolidation.md) +(after the prerequisite plan promotes it). No new ADR is required; this +plan is a direct application of the rules already accepted there. + +If implementation uncovers a design question not covered by the ADR (for +example, resume semantics on parameter-set mismatch), stop and ask +before proceeding. + +## Branch and PR + +- Branch: `feature/emcee-minimizer`. Do not push unless asked. +- Each step in §"Implementation steps (Phase 1)" must be staged with + explicit paths and committed locally **before** moving to the next + step. +- After P1.7, stop and wait for the user review gate before starting + Phase 2. + +## Decisions already made (from the ADR) + +1. emcee is exposed as a new concrete `minimizer` class + (`EmceeMinimizer`) registered under + `MinimizerTypeEnum.EMCEE = 'emcee'`. +2. Sampler settings reuse the verbose attribute names from ADR §5 + (`sampling_steps`, `burn_in_steps`, `thinning_interval`, + `population_size`, `parallel_workers`, `initialization_method`, + `random_seed`) with an emcee-specific addition: `proposal_moves`. +3. Resume uses emcee's `HDFBackend` against the `/emcee_chain` group of + the same `analysis/results.h5` file used by the snapshot writer. No + separate sidecar file. A non-resume `fit()` follows the prerequisite + plan's lifecycle and **truncates** `results.h5` (after the standard + warning); resume opens it in append mode. +4. The `fit()` action accepts an explicit `resume=True, extra_steps=N` + pair when the active minimizer supports incremental sampling. For + other minimizers, passing `resume=True` raises immediately. +5. emcee outputs translate to the existing `BayesianFitResults` shape + exactly as DREAM does — same `PosteriorSamples`, + `PosteriorParameterSummary`, etc. — so plotting and display code + needs no specialization. + +## Open questions + +- **Resume after parameter-set change.** If the user fits, then edits + which parameters are free, then calls `fit(resume=True, ...)`, emcee's + HDFBackend will fail because the dimensionality changed. Plan default: + detect mismatch and raise with a clear message asking the user to + start a fresh run. Confirm during P1.4. +- **Resume after a non-emcee fit.** If the user runs DREAM, then sets + `minimizer_type = 'emcee'`, then calls `fit(resume=True, ...)`, the + `/emcee_chain` group will be missing. Plan default: raise a clear + `ValueError` pointing at the prerequisite-plan lifecycle rule ("a new + fit overwrites the file"). +- **Move-mix semantics.** emcee supports proposal-move mixtures (e.g. 70 + % stretch + 30 % differential evolution). The ADR exposes + `proposal_moves` as a single string. Plan default: limit + `proposal_moves` to single-move strings for v1 (`stretch`, `de`, + `de_snooker`, `walk`). Mixtures deferred to a later plan. Record this + in the descriptor's `description=`. + +## Cleanup opportunities inherited from earlier work + +The consolidation work left four cleanup opportunities tracked in +[`docs/dev/issues/open.md`](../issues/open.md) that touch code this plan +will modify. Fold them in while the surrounding code is already being +edited, rather than queuing a separate refactor PR. + +- **F1 — Collapse duplicate predictive-cache-key helpers.** + `Analysis._predictive_cache_key` and + `Plotter._posterior_predictive_key` build the identical string; keep + one canonical helper. Tracked as [open-issue 100](../issues/open.md). +- **F4 — Drop dead branch in `Analysis._fit_state_categories`.** Both + branches return the same list since the Bayesian categories were + absorbed. Tracked as [open-issue 101](../issues/open.md). +- **F7 — Drop compute-and-ignore `result_kind` validation in + `_restore_persisted_fit_state`.** Replace with a validator helper or + move the warning into `fit_result.result_kind` setter. Tracked as + [open-issue 102](../issues/open.md). +- **F10 — Make `_sync_engine_from_minimizer_category` skip-keys + declarative.** This plan adds `proposal_moves` as a second + engine-level "ambient" key; introduce the `_engine_sync_skip_keys` + frozenset on `MinimizerCategoryBase` before adding the second member. + Tracked as [open-issue 103](../issues/open.md). + +When the matching open-issue is fully resolved, move it to +[`closed.md`](../issues/closed.md) and update +[`adrs/index.md`](../adrs/index.md) if relevant. + +## Concrete files likely to change + +Created: + +- `src/easydiffraction/analysis/categories/minimizer/emcee.py` (concrete + `EmceeMinimizer` class). +- `tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py`. +- `tests/integration/fitting/test_emcee.py` (cross-check vs DREAM on a + shared toy fit; assert posterior medians agree to within tolerance). +- `docs/docs/tutorials/ed-23.py` (emcee + resume tutorial). + +Modified: + +- `src/easydiffraction/analysis/minimizers/enums.py` (add + `MinimizerTypeEnum.EMCEE`). +- `src/easydiffraction/analysis/categories/minimizer/__init__.py` (add + the explicit `EmceeMinimizer` import to trigger registration). +- `src/easydiffraction/analysis/categories/minimizer/factory.py` (the + factory may need no change if registration uses `@Factory.register`). +- `src/easydiffraction/analysis/analysis.py` (`fit()` signature gains + `resume: bool = False, extra_steps: int | None = None`; route to the + live engine appropriately). +- `src/easydiffraction/io/results_sidecar.py` (read path: when + `/emcee_chain` is present, expose a small helper to construct an + `emcee.backends.HDFBackend(path, name='emcee_chain', read_only=...)`). +- `pyproject.toml` and `pixi.toml` (add `emcee>=3.1` dependency). + +## Implementation steps (Phase 1) + +- [ ] **P1.1 — Add emcee dependency.** Add `emcee>=3.1` to + `pyproject.toml` and `pixi.toml`. Run `pixi install` locally to + verify resolution. Commit: `Add emcee dependency` + +- [ ] **P1.2 — Register `MinimizerTypeEnum.EMCEE`.** Add the enum member + with value `'emcee'`. No other code wiring yet. Commit: + `Register emcee minimizer enum value` + +- [ ] **P1.3 — Add `EmceeMinimizer` concrete class.** Descriptor setup + follows the prerequisite plan's accepted helper pattern: + class-level defaults for emcee-specific values + (`sampling_steps=5000`, `population_size=32`, …, + `proposal_moves='stretch'`) and instance descriptors constructed + from the Bayesian minimizer helpers. Before wiring emcee, decide + whether DREAM's direct-engine `DreamPopulationInitializationEnum` + remains broader than the persisted `InitializationMethodEnum` + subset or is narrowed to match it. Implement `_native_kwargs()` + mapping to emcee's + `EnsembleSampler.run_mcmc(nsteps=..., progress=..., ...)`. Update + `src/easydiffraction/analysis/categories/minimizer/__init__.py` to + import `EmceeMinimizer` (registration trigger). Commit: + `Add EmceeMinimizer concrete class` + +- [ ] **P1.4 — Implement run + resume via HDFBackend.** In the live + solver layer (the new `Analysis._engine` path introduced in the + prerequisite plan), instantiate + `emcee.backends.HDFBackend(project.analysis_dir / 'results.h5', name='emcee_chain')`. + Lifecycle: + - **New fit** (`fit()` without `resume`): the prerequisite plan's + `Analysis.fit()` truncates `results.h5` _before_ the engine is asked + to sample (P1.10 in that plan). After truncation, the `HDFBackend` + is instantiated against the freshly recreated file and + `EnsembleSampler.run_mcmc(...)` is called. + - **Resume** (`fit(resume=True, extra_steps=N)`): + - require the active minimizer's `MinimizerTypeEnum` to support + resume (currently only `EMCEE`); + - require `results.h5` to exist and contain a `/emcee_chain` group + (raise `FileNotFoundError` / `ValueError` with a clear message + otherwise); + - reload the backend, validate `backend.shape` matches the current + parameter count (raise `ValueError` on mismatch with a clear + message and recommend starting a fresh fit); + - bypass the truncate-and-warn step; + - call + `run_mcmc(initial_state=None, nsteps=N, progress=True, skip_initial_state_check=True)` + to extend the chain. Translate the sampler's state to + `BayesianFitResults` exactly like DREAM, populating + `Parameter.posterior` via the existing helpers. Commit: + `Implement emcee run and resume via HDFBackend` + +- [ ] **P1.5 — Plug emcee outputs into existing posterior pipeline.** + Verify the existing sidecar writer for `/posterior`, + `/distribution_cache`, `/pair_cache`, `/predictive` correctly + picks up emcee results. Adjust only where emcee surfaces data + differently from DREAM (e.g. + `EnsembleSampler.get_chain(flat=False, discard=burn, thin=thin)` + vs the DREAM extraction helper). Cache derivations (KDE, pair + grids) must match the existing format. Commit: + `Route emcee posterior through sidecar pipeline` + +- [ ] **P1.6 — Add `ed-23.py` tutorial.** New notebook source at + `docs/docs/tutorials/ed-23.py`: demonstrate + `analysis.minimizer_type = 'emcee'`, a short run, save, resume + with `extra_steps=`, and a posterior plot. Run + `pixi run notebook-prepare` to generate the `.ipynb`. Commit: + `Add ed-23 emcee tutorial` + +- [ ] **P1.7 — Phase 1 review gate.** Stop and request user review + before Phase 2. + +## Verification (Phase 2) + +Same log-capture pattern as the prerequisite plan; commands repeated for +completeness. + +- [ ] **P2.1 — Add unit + integration tests.** + - `tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py`: + descriptor defaults, native-key mapping, swap behavior, resume + parameter-set-mismatch error path (no real sampler). + - `tests/integration/fitting/test_emcee.py`: end-to-end fit on a small + synthetic problem; resume; assert posterior medians agree with a + DREAM run within tolerance. + +- [ ] **P2.2 — Auto-fixes and static checks.** + + ``` + pixi run fix > /tmp/easydiffraction-fix.log 2>&1; \ + fix_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-fix.log; \ + exit $fix_exit_code + ``` + + ``` + pixi run check > /tmp/easydiffraction-check.log 2>&1; \ + check_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-check.log; \ + exit $check_exit_code + ``` + +- [ ] **P2.3 — Unit tests.** + + ``` + pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; \ + unit_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-unit-tests.log; \ + exit $unit_tests_exit_code + ``` + +- [ ] **P2.4 — Integration tests.** + + ``` + pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; \ + integration_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-integration-tests.log; \ + exit $integration_tests_exit_code + ``` + +- [ ] **P2.5 — Script tests.** + ``` + pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; \ + script_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-script-tests.log; \ + exit $script_tests_exit_code + ``` + +## Suggested Pull Request + +**Title:** Add emcee Bayesian sampler with resumable runs + +**Description (user-facing):** + +EasyDiffraction adds emcee — a widely-used affine-invariant MCMC sampler +— as a second Bayesian fitter. It is selected exactly like the existing +samplers: + +- `project.analysis.minimizer_type = 'emcee'` +- `project.analysis.minimizer.sampling_steps = 5000` +- `project.analysis.fit()` + +Long runs can be **resumed** without starting over: + +- `project.analysis.fit(resume=True, extra_steps=2000)` + +emcee's chain state lives inside the same `analysis/results.h5` file as +the other posterior data, so saving and reopening a project is a +single-file affair. Plots, parameter posteriors, and tables work the +same as for the existing DREAM sampler, so switching between samplers to +cross-check results is straightforward. + +A new tutorial (`ed-23`) walks through a short run, saving the project, +and resuming for additional steps. diff --git a/docs/docs/tutorials/ed-11.ipynb b/docs/docs/tutorials/ed-11.ipynb index 963894d86..4cd82dd22 100644 --- a/docs/docs/tutorials/ed-11.ipynb +++ b/docs/docs/tutorials/ed-11.ipynb @@ -82,8 +82,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.rendering.show_chart_engines()\n", - "project.rendering.show_config()" + "project.chart.show_supported()" ] }, { @@ -94,7 +93,7 @@ "outputs": [], "source": [ "# Set global plot range for plots\n", - "project.rendering.plotter.x_max = 40" + "project.chart.plotter.x_max = 40" ] }, { diff --git a/docs/docs/tutorials/ed-11.py b/docs/docs/tutorials/ed-11.py index e4a1ec991..de6e54bb6 100644 --- a/docs/docs/tutorials/ed-11.py +++ b/docs/docs/tutorials/ed-11.py @@ -21,12 +21,11 @@ # ## Set Plotting Engine # %% -project.rendering.show_chart_engines() -project.rendering.show_config() +project.chart.show_supported() # %% # Set global plot range for plots -project.rendering.plotter.x_max = 40 +project.chart.plotter.x_max = 40 # %% [markdown] # ## Add Structure diff --git a/docs/docs/tutorials/ed-12.ipynb b/docs/docs/tutorials/ed-12.ipynb index 176ab1475..62245c730 100644 --- a/docs/docs/tutorials/ed-12.ipynb +++ b/docs/docs/tutorials/ed-12.ipynb @@ -87,7 +87,7 @@ "source": [ "# Keep the auto-selected engine. Alternatively, you can uncomment the\n", "# line below to explicitly set the engine to the required one.\n", - "# project.rendering.chart_engine = 'plotly'" + "# project.chart.type = 'plotly'" ] }, { @@ -98,8 +98,8 @@ "outputs": [], "source": [ "# Set global plot range for plots\n", - "project.rendering.plotter.x_min = 2.0\n", - "project.rendering.plotter.x_max = 30.0" + "project.chart.plotter.x_min = 2.0\n", + "project.chart.plotter.x_max = 30.0" ] }, { @@ -192,7 +192,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['xray_pdf'].show_peak_profile_types()" + "project.experiments['xray_pdf'].peak.show_supported()" ] }, { @@ -202,7 +202,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['xray_pdf'].peak_profile_type = 'gaussian-damped-sinc'" + "project.experiments['xray_pdf'].peak.type = 'gaussian-damped-sinc'" ] }, { diff --git a/docs/docs/tutorials/ed-12.py b/docs/docs/tutorials/ed-12.py index 090f948b6..d1b5dd30e 100644 --- a/docs/docs/tutorials/ed-12.py +++ b/docs/docs/tutorials/ed-12.py @@ -26,12 +26,12 @@ # %% # Keep the auto-selected engine. Alternatively, you can uncomment the # line below to explicitly set the engine to the required one. -# project.rendering.chart_engine = 'plotly' +# project.chart.type = 'plotly' # %% # Set global plot range for plots -project.rendering.plotter.x_min = 2.0 -project.rendering.plotter.x_max = 30.0 +project.chart.plotter.x_min = 2.0 +project.chart.plotter.x_max = 30.0 # %% [markdown] # ## Add Structure @@ -79,10 +79,10 @@ ) # %% -project.experiments['xray_pdf'].show_peak_profile_types() +project.experiments['xray_pdf'].peak.show_supported() # %% -project.experiments['xray_pdf'].peak_profile_type = 'gaussian-damped-sinc' +project.experiments['xray_pdf'].peak.type = 'gaussian-damped-sinc' # %% project.experiments['xray_pdf'].peak.damp_q = 0.03 diff --git a/docs/docs/tutorials/ed-13.ipynb b/docs/docs/tutorials/ed-13.ipynb index ca636d82b..a99270afa 100644 --- a/docs/docs/tutorials/ed-13.ipynb +++ b/docs/docs/tutorials/ed-13.ipynb @@ -538,7 +538,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.experiments['sim_si'].show_peak_profile_types()" + "project_1.experiments['sim_si'].peak.show_supported()" ] }, { @@ -609,7 +609,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.experiments['sim_si'].show_background_types()" + "project_1.experiments['sim_si'].background.show_supported()" ] }, { @@ -619,7 +619,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.experiments['sim_si'].background_type = 'line-segment'\n", + "project_1.experiments['sim_si'].background.type = 'line-segment'\n", "project_1.experiments['sim_si'].background.create(id='1', x=50000, y=0.01)\n", "project_1.experiments['sim_si'].background.create(id='2', x=60000, y=0.01)\n", "project_1.experiments['sim_si'].background.create(id='3', x=70000, y=0.01)\n", diff --git a/docs/docs/tutorials/ed-13.py b/docs/docs/tutorials/ed-13.py index 436c13b93..18400d55b 100644 --- a/docs/docs/tutorials/ed-13.py +++ b/docs/docs/tutorials/ed-13.py @@ -312,7 +312,7 @@ # for more details about the peak profile types. # %% -project_1.experiments['sim_si'].show_peak_profile_types() +project_1.experiments['sim_si'].peak.show_supported() # %% project_1.experiments['sim_si'].peak.broad_gauss_sigma_0 = 69498 @@ -359,10 +359,10 @@ # for more details about the background and its types. # %% -project_1.experiments['sim_si'].show_background_types() +project_1.experiments['sim_si'].background.show_supported() # %% -project_1.experiments['sim_si'].background_type = 'line-segment' +project_1.experiments['sim_si'].background.type = 'line-segment' project_1.experiments['sim_si'].background.create(id='1', x=50000, y=0.01) project_1.experiments['sim_si'].background.create(id='2', x=60000, y=0.01) project_1.experiments['sim_si'].background.create(id='3', x=70000, y=0.01) diff --git a/docs/docs/tutorials/ed-15.ipynb b/docs/docs/tutorials/ed-15.ipynb index e79bdd876..bdc9385cc 100644 --- a/docs/docs/tutorials/ed-15.ipynb +++ b/docs/docs/tutorials/ed-15.ipynb @@ -229,7 +229,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -239,7 +239,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'bumps'" + "project.analysis.minimizer.type = 'bumps'" ] }, { @@ -250,7 +250,7 @@ "outputs": [], "source": [ "# Limit number of iterations to prevent long calculation time in this tutorial.\n", - "project.analysis.fitting.minimizer.max_iterations = 500" + "project.analysis.minimizer.max_iterations = 500" ] }, { diff --git a/docs/docs/tutorials/ed-15.py b/docs/docs/tutorials/ed-15.py index 03dc35fa7..cc9776288 100644 --- a/docs/docs/tutorials/ed-15.py +++ b/docs/docs/tutorials/ed-15.py @@ -74,14 +74,14 @@ experiment.extinction.radius.free = True # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% -project.analysis.fitting.minimizer_type = 'bumps' +project.analysis.minimizer.type = 'bumps' # %% # Limit number of iterations to prevent long calculation time in this tutorial. -project.analysis.fitting.minimizer.max_iterations = 500 +project.analysis.minimizer.max_iterations = 500 # %% # Start refinement. All parameters, which have standard uncertainties diff --git a/docs/docs/tutorials/ed-16.ipynb b/docs/docs/tutorials/ed-16.ipynb index b30be9754..c57d1cd95 100644 --- a/docs/docs/tutorials/ed-16.ipynb +++ b/docs/docs/tutorials/ed-16.ipynb @@ -223,7 +223,7 @@ "metadata": {}, "outputs": [], "source": [ - "bragg_expt.peak_profile_type = 'jorgensen'\n", + "bragg_expt.peak.type = 'jorgensen'\n", "bragg_expt.peak.broad_gauss_sigma_0 = 5.0\n", "bragg_expt.peak.broad_gauss_sigma_1 = 45.0\n", "bragg_expt.peak.broad_gauss_sigma_2 = 1.0\n", @@ -248,7 +248,7 @@ "metadata": {}, "outputs": [], "source": [ - "bragg_expt.background_type = 'line-segment'\n", + "bragg_expt.background.type = 'line-segment'\n", "for x in range(0, 35000, 5000):\n", " bragg_expt.background.create(id=str(x), x=x, y=200)" ] @@ -435,7 +435,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting_mode_type = 'joint'\n", + "project.analysis.fitting_mode.type = 'joint'\n", "project.analysis.joint_fit.create(experiment_id='sepd', weight=0.7)\n", "project.analysis.joint_fit.create(experiment_id='nomad', weight=0.3)" ] diff --git a/docs/docs/tutorials/ed-16.py b/docs/docs/tutorials/ed-16.py index e5520bfe3..7f3074c61 100644 --- a/docs/docs/tutorials/ed-16.py +++ b/docs/docs/tutorials/ed-16.py @@ -90,7 +90,7 @@ # #### Set Peak Profile # %% -bragg_expt.peak_profile_type = 'jorgensen' +bragg_expt.peak.type = 'jorgensen' bragg_expt.peak.broad_gauss_sigma_0 = 5.0 bragg_expt.peak.broad_gauss_sigma_1 = 45.0 bragg_expt.peak.broad_gauss_sigma_2 = 1.0 @@ -103,7 +103,7 @@ # #### Set Background # %% -bragg_expt.background_type = 'line-segment' +bragg_expt.background.type = 'line-segment' for x in range(0, 35000, 5000): bragg_expt.background.create(id=str(x), x=x, y=200) @@ -182,7 +182,7 @@ # #### Set Fit Mode and Weights # %% -project.analysis.fitting_mode_type = 'joint' +project.analysis.fitting_mode.type = 'joint' project.analysis.joint_fit.create(experiment_id='sepd', weight=0.7) project.analysis.joint_fit.create(experiment_id='nomad', weight=0.3) diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb index e471d99a7..89f0488ca 100644 --- a/docs/docs/tutorials/ed-17.ipynb +++ b/docs/docs/tutorials/ed-17.ipynb @@ -534,7 +534,7 @@ "metadata": {}, "outputs": [], "source": [ - "analysis.fitting.minimizer_type = 'bumps (lm)'" + "analysis.minimizer.type = 'bumps (lm)'" ] }, { @@ -679,7 +679,7 @@ "metadata": {}, "outputs": [], "source": [ - "analysis.fitting_mode_type = 'sequential'\n", + "analysis.fitting_mode.type = 'sequential'\n", "analysis.sequential_fit.data_dir = scan_data_dir\n", "analysis.sequential_fit.max_workers = 'auto'\n", "analysis.sequential_fit.reverse = True" diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py index 93f691fb7..3f8f9b1ed 100644 --- a/docs/docs/tutorials/ed-17.py +++ b/docs/docs/tutorials/ed-17.py @@ -269,7 +269,7 @@ # #### Set Minimizer # %% -analysis.fitting.minimizer_type = 'bumps (lm)' +analysis.minimizer.type = 'bumps (lm)' # %% [markdown] # #### Run Single Fitting @@ -327,7 +327,7 @@ # Set the sequential fitting parameters. # %% -analysis.fitting_mode_type = 'sequential' +analysis.fitting_mode.type = 'sequential' analysis.sequential_fit.data_dir = scan_data_dir analysis.sequential_fit.max_workers = 'auto' analysis.sequential_fit.reverse = True diff --git a/docs/docs/tutorials/ed-2.ipynb b/docs/docs/tutorials/ed-2.ipynb index 1212cbcc7..fb664c97e 100644 --- a/docs/docs/tutorials/ed-2.ipynb +++ b/docs/docs/tutorials/ed-2.ipynb @@ -408,8 +408,8 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()\n", - "project.analysis.fitting.minimizer_type = 'lmfit'" + "project.analysis.minimizer.show_supported()\n", + "project.analysis.minimizer.type = 'lmfit'" ] }, { @@ -467,7 +467,7 @@ "metadata": {}, "outputs": [], "source": [ - "experiment.calculation.show_calculator_types()" + "experiment.calculator.show_supported()" ] }, { @@ -477,7 +477,7 @@ "metadata": {}, "outputs": [], "source": [ - "experiment.calculation.calculator_type = 'crysfml'" + "experiment.calculator.type = 'crysfml'" ] }, { diff --git a/docs/docs/tutorials/ed-2.py b/docs/docs/tutorials/ed-2.py index 220ebae9c..097720a88 100644 --- a/docs/docs/tutorials/ed-2.py +++ b/docs/docs/tutorials/ed-2.py @@ -192,8 +192,8 @@ project.analysis.constraints.create(expression='biso_Ba = biso_La') # %% -project.analysis.fitting.show_minimizer_types() -project.analysis.fitting.minimizer_type = 'lmfit' +project.analysis.minimizer.show_supported() +project.analysis.minimizer.type = 'lmfit' # %% project.analysis.fit() @@ -211,10 +211,10 @@ # ## Step 6: Switch calculator engine # %% -experiment.calculation.show_calculator_types() +experiment.calculator.show_supported() # %% -experiment.calculation.calculator_type = 'crysfml' +experiment.calculator.type = 'crysfml' # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-20.ipynb b/docs/docs/tutorials/ed-20.ipynb index 086d7d185..a4bc863f5 100644 --- a/docs/docs/tutorials/ed-20.ipynb +++ b/docs/docs/tutorials/ed-20.ipynb @@ -246,7 +246,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt_s2.show_peak_profile_types()" + "expt_s2.peak.show_supported()" ] }, { @@ -256,7 +256,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt_s2.peak_profile_type = 'pseudo-voigt'" + "expt_s2.peak.type = 'pseudo-voigt'" ] }, { @@ -278,7 +278,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt_n2.peak_profile_type = 'pseudo-voigt'" + "expt_n2.peak.type = 'pseudo-voigt'" ] }, { @@ -308,7 +308,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt_s2.show_background_types()" + "expt_s2.background.show_supported()" ] }, { @@ -318,7 +318,7 @@ "metadata": {}, "outputs": [], "source": [ - "# expt_s2.background_type = 'line-segment'" + "# expt_s2.background.type = 'line-segment'" ] }, { @@ -548,7 +548,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.show_fitting_mode_types()" + "project.analysis.fitting_mode.show_supported()" ] }, { @@ -558,7 +558,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting_mode_type = 'joint'" + "project.analysis.fitting_mode.type = 'joint'" ] }, { diff --git a/docs/docs/tutorials/ed-20.py b/docs/docs/tutorials/ed-20.py index 1fcc6d24a..edb4b0269 100644 --- a/docs/docs/tutorials/ed-20.py +++ b/docs/docs/tutorials/ed-20.py @@ -123,10 +123,10 @@ # #### Set Peak Profile # %% -expt_s2.show_peak_profile_types() +expt_s2.peak.show_supported() # %% -expt_s2.peak_profile_type = 'pseudo-voigt' +expt_s2.peak.type = 'pseudo-voigt' # %% expt_s2.peak.broad_gauss_sigma_0 = 300 @@ -134,7 +134,7 @@ expt_s2.peak.broad_gauss_sigma_2 = 900 # %% -expt_n2.peak_profile_type = 'pseudo-voigt' +expt_n2.peak.type = 'pseudo-voigt' # %% expt_n2.peak.broad_gauss_sigma_0 = 300 @@ -145,10 +145,10 @@ # #### Set Background # %% -expt_s2.show_background_types() +expt_s2.background.show_supported() # %% -# expt_s2.background_type = 'line-segment' +# expt_s2.background.type = 'line-segment' # %% for idx, (x, y) in enumerate( @@ -259,10 +259,10 @@ # #### Set Fit Mode # %% -project.analysis.show_fitting_mode_types() +project.analysis.fitting_mode.show_supported() # %% -project.analysis.fitting_mode_type = 'joint' +project.analysis.fitting_mode.type = 'joint' # %% [markdown] # #### Set Free Parameters diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index 689c3d57a..2b02a7c63 100644 --- a/docs/docs/tutorials/ed-21.ipynb +++ b/docs/docs/tutorials/ed-21.ipynb @@ -428,7 +428,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -438,7 +438,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'bumps (lm)'" + "project.analysis.minimizer.type = 'bumps (lm)'" ] }, { @@ -597,7 +597,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -607,7 +607,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'bumps (dream)'" + "project.analysis.minimizer.type = 'bumps (dream)'" ] }, { @@ -617,8 +617,8 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer.steps = 100 # lower than the default 3000\n", - "project.analysis.fitting.minimizer.burn = 20 # lower than the default 600" + "project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000\n", + "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600" ] }, { diff --git a/docs/docs/tutorials/ed-21.py b/docs/docs/tutorials/ed-21.py index df32bcabe..563992ee1 100644 --- a/docs/docs/tutorials/ed-21.py +++ b/docs/docs/tutorials/ed-21.py @@ -210,10 +210,10 @@ # and uncertainty estimates for the Bayesian run. # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% -project.analysis.fitting.minimizer_type = 'bumps (lm)' +project.analysis.minimizer.type = 'bumps (lm)' # %% project.analysis.fit() @@ -291,14 +291,14 @@ # this is not recommended for production analysis. # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% -project.analysis.fitting.minimizer_type = 'bumps (dream)' +project.analysis.minimizer.type = 'bumps (dream)' # %% -project.analysis.fitting.minimizer.steps = 100 # lower than the default 3000 -project.analysis.fitting.minimizer.burn = 20 # lower than the default 600 +project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000 +project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600 # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-22.ipynb b/docs/docs/tutorials/ed-22.ipynb index a8d511132..8825c90dc 100644 --- a/docs/docs/tutorials/ed-22.ipynb +++ b/docs/docs/tutorials/ed-22.ipynb @@ -294,7 +294,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -462,7 +462,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -472,7 +472,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'bumps (dream)'" + "project.analysis.minimizer.type = 'bumps (dream)'" ] }, { @@ -482,8 +482,8 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer.steps = 100 # lower than the default 3000\n", - "project.analysis.fitting.minimizer.burn = 20 # lower than the default 600" + "project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000\n", + "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600" ] }, { diff --git a/docs/docs/tutorials/ed-22.py b/docs/docs/tutorials/ed-22.py index cbc08f74e..4b6c99eab 100644 --- a/docs/docs/tutorials/ed-22.py +++ b/docs/docs/tutorials/ed-22.py @@ -131,7 +131,7 @@ # and uncertainty estimates for the Bayesian run. # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% project.analysis.fit() @@ -213,14 +213,14 @@ # effective burn-in is recomputed automatically. # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% -project.analysis.fitting.minimizer_type = 'bumps (dream)' +project.analysis.minimizer.type = 'bumps (dream)' # %% -project.analysis.fitting.minimizer.steps = 100 # lower than the default 3000 -project.analysis.fitting.minimizer.burn = 20 # lower than the default 600 +project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000 +project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600 # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-24.ipynb b/docs/docs/tutorials/ed-24.ipynb index 745d962f8..869a30919 100644 --- a/docs/docs/tutorials/ed-24.ipynb +++ b/docs/docs/tutorials/ed-24.ipynb @@ -46,10 +46,54 @@ "cell_type": "code", "execution_count": null, "id": "3", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 1 + }, "outputs": [], "source": [ - "import easydiffraction as ed" + "from pathlib import Path\n", + "\n", + "import easydiffraction as ed\n", + "\n", + "\n", + "# The ID 35 archive used below was saved before the\n", + "# switchable-category-owned-selectors refactor renamed several CIF\n", + "# tags. The helper below rewrites the archive in place so the tutorial\n", + "# can load it; it is intentionally narrow (ID 35 only, hrpt only,\n", + "# line-segment background only) and not a general legacy migration\n", + "# path. EasyDiffraction is in beta and does not ship legacy CIF\n", + "# shims, so saved projects in the old layout must be regenerated. The\n", + "# helper will be deleted once the upstream archive is republished\n", + "# under the current tag names.\n", + "def _normalize_id35_archive_for_tutorial(project_dir):\n", + " \"\"\"Rewrite the ID 35 archive's CIF tags for the current API.\"\"\"\n", + " project_path = Path(project_dir)\n", + "\n", + " replacements_by_file = {\n", + " project_path / 'project.cif': {\n", + " '_rendering.chart_engine': '_chart.type',\n", + " '_rendering.table_engine': '_table.type',\n", + " },\n", + " project_path / 'analysis' / 'analysis.cif': {\n", + " '_fitting.mode_type': '_fitting_mode.type',\n", + " '_fitting.minimizer_type': '_minimizer.type',\n", + " },\n", + " project_path / 'experiments' / 'hrpt.cif': {\n", + " '_calculation.calculator_type': '_calculator.type',\n", + " '_peak.profile_type': '_peak.type',\n", + " },\n", + " }\n", + "\n", + " for file_path, replacements in replacements_by_file.items():\n", + " text = file_path.read_text(encoding='utf-8')\n", + " for old, new in replacements.items():\n", + " text = text.replace(old, new)\n", + " if file_path.name == 'hrpt.cif' and '_background.type' not in text:\n", + " text = text.replace(\n", + " '\\nloop_\\n_pd_background.id\\n',\n", + " '\\n_background.type line-segment\\nloop_\\n_pd_background.id\\n',\n", + " )\n", + " file_path.write_text(text, encoding='utf-8')" ] }, { @@ -71,7 +115,8 @@ "metadata": {}, "outputs": [], "source": [ - "project_dir = ed.download_data(id=35, destination='projects')" + "project_dir = ed.download_data(id=35, destination='projects')\n", + "_normalize_id35_archive_for_tutorial(project_dir)" ] }, { diff --git a/docs/docs/tutorials/ed-24.py b/docs/docs/tutorials/ed-24.py index 9e5d40f87..01f3daff9 100644 --- a/docs/docs/tutorials/ed-24.py +++ b/docs/docs/tutorials/ed-24.py @@ -12,8 +12,51 @@ # ## Import Library # %% +from pathlib import Path + import easydiffraction as ed + +# The ID 35 archive used below was saved before the +# switchable-category-owned-selectors refactor renamed several CIF +# tags. The helper below rewrites the archive in place so the tutorial +# can load it; it is intentionally narrow (ID 35 only, hrpt only, +# line-segment background only) and not a general legacy migration +# path. EasyDiffraction is in beta and does not ship legacy CIF +# shims, so saved projects in the old layout must be regenerated. The +# helper will be deleted once the upstream archive is republished +# under the current tag names. +def _normalize_id35_archive_for_tutorial(project_dir): + """Rewrite the ID 35 archive's CIF tags for the current API.""" + project_path = Path(project_dir) + + replacements_by_file = { + project_path / 'project.cif': { + '_rendering.chart_engine': '_chart.type', + '_rendering.table_engine': '_table.type', + }, + project_path / 'analysis' / 'analysis.cif': { + '_fitting.mode_type': '_fitting_mode.type', + '_fitting.minimizer_type': '_minimizer.type', + }, + project_path / 'experiments' / 'hrpt.cif': { + '_calculation.calculator_type': '_calculator.type', + '_peak.profile_type': '_peak.type', + }, + } + + for file_path, replacements in replacements_by_file.items(): + text = file_path.read_text(encoding='utf-8') + for old, new in replacements.items(): + text = text.replace(old, new) + if file_path.name == 'hrpt.cif' and '_background.type' not in text: + text = text.replace( + '\nloop_\n_pd_background.id\n', + '\n_background.type line-segment\nloop_\n_pd_background.id\n', + ) + file_path.write_text(text, encoding='utf-8') + + # %% [markdown] # ## Download Saved Project # @@ -23,6 +66,7 @@ # %% project_dir = ed.download_data(id=35, destination='projects') +_normalize_id35_archive_for_tutorial(project_dir) # %% [markdown] # ## Load the Saved Bayesian Project diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb index 357607f26..dce64e629 100644 --- a/docs/docs/tutorials/ed-3.ipynb +++ b/docs/docs/tutorials/ed-3.ipynb @@ -175,7 +175,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.rendering.show_chart_engines()" + "project.chart.show_supported()" ] }, { @@ -193,7 +193,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.rendering.show_config()" + "project.chart.show_supported()" ] }, { @@ -533,7 +533,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].show_peak_profile_types()" + "project.experiments['hrpt'].peak.show_supported()" ] }, { @@ -551,7 +551,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].peak_profile_type = 'pseudo-voigt'" + "project.experiments['hrpt'].peak.type = 'pseudo-voigt'" ] }, { @@ -599,7 +599,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].show_background_types()" + "project.experiments['hrpt'].background.show_supported()" ] }, { @@ -617,7 +617,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].background_type = 'line-segment'" + "project.experiments['hrpt'].background.type = 'line-segment'" ] }, { @@ -738,7 +738,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].calculation.show_calculator_types()" + "project.experiments['hrpt'].calculator.show_supported()" ] }, { @@ -756,7 +756,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.experiments['hrpt'].calculation.calculator_type = 'cryspy'" + "project.experiments['hrpt'].calculator.type = 'cryspy'" ] }, { @@ -896,7 +896,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.show_fitting_mode_types()" + "project.analysis.fitting_mode.show_supported()" ] }, { @@ -914,7 +914,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting_mode_type = 'single'" + "project.analysis.fitting_mode.type = 'single'" ] }, { @@ -934,7 +934,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.show_minimizer_types()" + "project.analysis.minimizer.show_supported()" ] }, { @@ -952,7 +952,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'lmfit'" + "project.analysis.minimizer.type = 'lmfit'" ] }, { diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py index 7a194acd2..bc6fcda49 100644 --- a/docs/docs/tutorials/ed-3.py +++ b/docs/docs/tutorials/ed-3.py @@ -68,13 +68,13 @@ # Show supported plotting engines. # %% -project.rendering.show_chart_engines() +project.chart.show_supported() # %% [markdown] # Show current plotting configuration. # %% -project.rendering.show_config() +project.chart.show_supported() # %% [markdown] # ## Step 2: Define Structure @@ -236,13 +236,13 @@ # Show supported peak profile types. # %% -project.experiments['hrpt'].show_peak_profile_types() +project.experiments['hrpt'].peak.show_supported() # %% [markdown] # Select the desired peak profile type. # %% -project.experiments['hrpt'].peak_profile_type = 'pseudo-voigt' +project.experiments['hrpt'].peak.type = 'pseudo-voigt' # %% [markdown] # Modify default peak profile parameters. @@ -261,13 +261,13 @@ # Show supported background types. # %% -project.experiments['hrpt'].show_background_types() +project.experiments['hrpt'].background.show_supported() # %% [markdown] # Select the desired background type. # %% -project.experiments['hrpt'].background_type = 'line-segment' +project.experiments['hrpt'].background.type = 'line-segment' # %% [markdown] # Add background points. @@ -316,13 +316,13 @@ # Show supported calculation engines for this experiment. # %% -project.experiments['hrpt'].calculation.show_calculator_types() +project.experiments['hrpt'].calculator.show_supported() # %% [markdown] # Select the desired calculation engine. # %% -project.experiments['hrpt'].calculation.calculator_type = 'cryspy' +project.experiments['hrpt'].calculator.type = 'cryspy' # %% [markdown] # #### Show Calculated Data @@ -371,13 +371,13 @@ # Show supported fit modes. # %% -project.analysis.show_fitting_mode_types() +project.analysis.fitting_mode.show_supported() # %% [markdown] # Select desired fit mode. # %% -project.analysis.fitting_mode_type = 'single' +project.analysis.fitting_mode.type = 'single' # %% [markdown] # #### Set Minimizer @@ -385,13 +385,13 @@ # Show supported fitting engines. # %% -project.analysis.fitting.show_minimizer_types() +project.analysis.minimizer.show_supported() # %% [markdown] # Select desired fitting engine. # %% -project.analysis.fitting.minimizer_type = 'lmfit' +project.analysis.minimizer.type = 'lmfit' # %% [markdown] # ### Perform Fit 1/5 diff --git a/docs/docs/tutorials/ed-4.ipynb b/docs/docs/tutorials/ed-4.ipynb index ec70ad9e1..c80f9f46e 100644 --- a/docs/docs/tutorials/ed-4.ipynb +++ b/docs/docs/tutorials/ed-4.ipynb @@ -294,7 +294,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt1.background_type = 'line-segment'" + "expt1.background.type = 'line-segment'" ] }, { @@ -449,7 +449,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt2.background_type = 'chebyshev'" + "expt2.background.type = 'chebyshev'" ] }, { @@ -576,7 +576,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting_mode_type = 'joint'" + "project.analysis.fitting_mode.type = 'joint'" ] }, { @@ -594,7 +594,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.fitting.minimizer_type = 'lmfit'" + "project.analysis.minimizer.type = 'lmfit'" ] }, { diff --git a/docs/docs/tutorials/ed-4.py b/docs/docs/tutorials/ed-4.py index e0362a8c2..5c1047360 100644 --- a/docs/docs/tutorials/ed-4.py +++ b/docs/docs/tutorials/ed-4.py @@ -143,7 +143,7 @@ # Select the background type. # %% -expt1.background_type = 'line-segment' +expt1.background.type = 'line-segment' # %% [markdown] # Add background points. @@ -209,7 +209,7 @@ # Select background type. # %% -expt2.background_type = 'chebyshev' +expt2.background.type = 'chebyshev' # %% [markdown] # Add background points. @@ -264,13 +264,13 @@ # #### Set Fit Mode # %% -project.analysis.fitting_mode_type = 'joint' +project.analysis.fitting_mode.type = 'joint' # %% [markdown] # #### Set Minimizer # %% -project.analysis.fitting.minimizer_type = 'lmfit' +project.analysis.minimizer.type = 'lmfit' # %% [markdown] # #### Set Fitting Parameters diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb index 3fa6294b9..5dd0e7a06 100644 --- a/docs/docs/tutorials/ed-5.ipynb +++ b/docs/docs/tutorials/ed-5.ipynb @@ -263,7 +263,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.show_peak_profile_types()" + "expt.peak.show_supported()" ] }, { @@ -273,7 +273,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.peak_profile_type = 'pseudo-voigt + empirical asymmetry'" + "expt.peak.type = 'pseudo-voigt + empirical asymmetry'" ] }, { @@ -303,7 +303,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.show_background_types()" + "expt.background.show_supported()" ] }, { diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py index a82990a32..7c307b1bd 100644 --- a/docs/docs/tutorials/ed-5.py +++ b/docs/docs/tutorials/ed-5.py @@ -130,10 +130,10 @@ # #### Set Peak Profile # %% -expt.show_peak_profile_types() +expt.peak.show_supported() # %% -expt.peak_profile_type = 'pseudo-voigt + empirical asymmetry' +expt.peak.type = 'pseudo-voigt + empirical asymmetry' # %% expt.peak.broad_gauss_u = 0.3 @@ -144,7 +144,7 @@ # #### Set Background # %% -expt.show_background_types() +expt.background.show_supported() # %% expt.background.create(id='1', x=8, y=500) diff --git a/docs/docs/tutorials/ed-6.ipynb b/docs/docs/tutorials/ed-6.ipynb index 3c89362d1..c8a7fe4b0 100644 --- a/docs/docs/tutorials/ed-6.ipynb +++ b/docs/docs/tutorials/ed-6.ipynb @@ -252,8 +252,8 @@ "metadata": {}, "outputs": [], "source": [ - "expt.show_peak_profile_types()\n", - "expt.peak_profile_type = 'pseudo-voigt + empirical asymmetry'\n", + "expt.peak.show_supported()\n", + "expt.peak.type = 'pseudo-voigt + empirical asymmetry'\n", "expt.peak.broad_gauss_u = 0.1\n", "expt.peak.broad_gauss_v = -0.2\n", "expt.peak.broad_gauss_w = 0.2\n", diff --git a/docs/docs/tutorials/ed-6.py b/docs/docs/tutorials/ed-6.py index 14d93515e..33477e8eb 100644 --- a/docs/docs/tutorials/ed-6.py +++ b/docs/docs/tutorials/ed-6.py @@ -118,8 +118,8 @@ # #### Set Peak Profile # %% -expt.show_peak_profile_types() -expt.peak_profile_type = 'pseudo-voigt + empirical asymmetry' +expt.peak.show_supported() +expt.peak.type = 'pseudo-voigt + empirical asymmetry' expt.peak.broad_gauss_u = 0.1 expt.peak.broad_gauss_v = -0.2 expt.peak.broad_gauss_w = 0.2 diff --git a/docs/docs/tutorials/ed-7.ipynb b/docs/docs/tutorials/ed-7.ipynb index 31294b586..cf5f0d0e9 100644 --- a/docs/docs/tutorials/ed-7.ipynb +++ b/docs/docs/tutorials/ed-7.ipynb @@ -218,7 +218,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.show_peak_profile_types()\n", + "expt.peak.show_supported()\n", "expt.peak.broad_gauss_sigma_0 = 3.0\n", "expt.peak.broad_gauss_sigma_1 = 40.0\n", "expt.peak.broad_gauss_sigma_2 = 2.0\n", @@ -243,7 +243,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.background_type = 'line-segment'\n", + "expt.background.type = 'line-segment'\n", "for x in range(0, 35000, 5000):\n", " expt.background.create(id=str(x), x=x, y=200)" ] @@ -765,7 +765,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.calculation.show_calculator_types()" + "expt.calculator.show_supported()" ] }, { @@ -775,7 +775,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.calculation.calculator_type = 'crysfml'" + "expt.calculator.type = 'crysfml'" ] }, { @@ -793,7 +793,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.show_peak_profile_types()" + "expt.peak.show_supported()" ] }, { @@ -803,7 +803,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt.peak_profile_type = 'jorgensen-von-dreele'" + "expt.peak.type = 'jorgensen-von-dreele'" ] }, { diff --git a/docs/docs/tutorials/ed-7.py b/docs/docs/tutorials/ed-7.py index ed3dcdaec..5f1c3b432 100644 --- a/docs/docs/tutorials/ed-7.py +++ b/docs/docs/tutorials/ed-7.py @@ -85,7 +85,7 @@ # #### Set Peak Profile # %% -expt.show_peak_profile_types() +expt.peak.show_supported() expt.peak.broad_gauss_sigma_0 = 3.0 expt.peak.broad_gauss_sigma_1 = 40.0 expt.peak.broad_gauss_sigma_2 = 2.0 @@ -98,7 +98,7 @@ # #### Set Background # %% -expt.background_type = 'line-segment' +expt.background.type = 'line-segment' for x in range(0, 35000, 5000): expt.background.create(id=str(x), x=x, y=200) @@ -296,19 +296,19 @@ # #### Switch calculator engine # %% -expt.calculation.show_calculator_types() +expt.calculator.show_supported() # %% -expt.calculation.calculator_type = 'crysfml' +expt.calculator.type = 'crysfml' # %% [markdown] # #### Change peak profile type # %% -expt.show_peak_profile_types() +expt.peak.show_supported() # %% -expt.peak_profile_type = 'jorgensen-von-dreele' +expt.peak.type = 'jorgensen-von-dreele' # %% expt.peak.broad_gauss_sigma_0 = 3.0148 diff --git a/docs/docs/tutorials/ed-8.ipynb b/docs/docs/tutorials/ed-8.ipynb index 528eee528..6bb625de4 100644 --- a/docs/docs/tutorials/ed-8.ipynb +++ b/docs/docs/tutorials/ed-8.ipynb @@ -304,7 +304,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt56.show_peak_profile_types()\n", + "expt56.peak.show_supported()\n", "expt56.peak.broad_gauss_sigma_0 = 0.0\n", "expt56.peak.broad_gauss_sigma_1 = 0.0\n", "expt56.peak.broad_gauss_sigma_2 = 15.5\n", @@ -345,8 +345,8 @@ "metadata": {}, "outputs": [], "source": [ - "expt56.show_background_types()\n", - "expt56.background_type = 'line-segment'\n", + "expt56.background.show_supported()\n", + "expt56.background.type = 'line-segment'\n", "for idx, (x, y) in enumerate(\n", " [\n", " (9162, 465),\n", @@ -390,7 +390,7 @@ "metadata": {}, "outputs": [], "source": [ - "expt47.background_type = 'line-segment'\n", + "expt47.background.type = 'line-segment'\n", "for idx, (x, y) in enumerate(\n", " [\n", " (9090, 488),\n", @@ -564,8 +564,8 @@ "metadata": {}, "outputs": [], "source": [ - "project.analysis.show_fitting_mode_types()\n", - "project.analysis.fitting_mode_type = 'joint'" + "project.analysis.fitting_mode.show_supported()\n", + "project.analysis.fitting_mode.type = 'joint'" ] }, { diff --git a/docs/docs/tutorials/ed-8.py b/docs/docs/tutorials/ed-8.py index 7491684ba..c158afa95 100644 --- a/docs/docs/tutorials/ed-8.py +++ b/docs/docs/tutorials/ed-8.py @@ -150,7 +150,7 @@ # #### Set Peak Profile # %% -expt56.show_peak_profile_types() +expt56.peak.show_supported() expt56.peak.broad_gauss_sigma_0 = 0.0 expt56.peak.broad_gauss_sigma_1 = 0.0 expt56.peak.broad_gauss_sigma_2 = 15.5 @@ -172,8 +172,8 @@ # #### Set Background # %% -expt56.show_background_types() -expt56.background_type = 'line-segment' +expt56.background.show_supported() +expt56.background.type = 'line-segment' for idx, (x, y) in enumerate( [ (9162, 465), @@ -210,7 +210,7 @@ expt56.background.create(id=str(idx), x=x, y=y) # %% -expt47.background_type = 'line-segment' +expt47.background.type = 'line-segment' for idx, (x, y) in enumerate( [ (9090, 488), @@ -298,8 +298,8 @@ # #### Set Fit Mode # %% -project.analysis.show_fitting_mode_types() -project.analysis.fitting_mode_type = 'joint' +project.analysis.fitting_mode.show_supported() +project.analysis.fitting_mode.type = 'joint' # %% [markdown] # #### Set Free Parameters diff --git a/docs/docs/tutorials/ed-9.ipynb b/docs/docs/tutorials/ed-9.ipynb index 0bfd09490..25a26b141 100644 --- a/docs/docs/tutorials/ed-9.ipynb +++ b/docs/docs/tutorials/ed-9.ipynb @@ -362,7 +362,7 @@ "metadata": {}, "outputs": [], "source": [ - "experiment.background_type = 'line-segment'" + "experiment.background.type = 'line-segment'" ] }, { diff --git a/docs/docs/tutorials/ed-9.py b/docs/docs/tutorials/ed-9.py index ffae09108..383670730 100644 --- a/docs/docs/tutorials/ed-9.py +++ b/docs/docs/tutorials/ed-9.py @@ -164,7 +164,7 @@ # Select the background type. # %% -experiment.background_type = 'line-segment' +experiment.background.type = 'line-segment' # %% [markdown] # Add background points. diff --git a/pixi.lock b/pixi.lock index 974cbe77e..c421cdaaa 100644 --- a/pixi.lock +++ b/pixi.lock @@ -32,7 +32,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-7_h0358290_openblas.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/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.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 @@ -43,27 +43,27 @@ environments: - 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/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.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.1-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/libuuid-2.42-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-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/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-25.8.2-he4ff34a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.2.0-he4ff34a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.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.4-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.5-habeac84_100_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_2.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-0.30.0-py314h2e6c369_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.5-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-h41580af_10.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.13.0-pyhcf101f3_0.conda @@ -77,14 +77,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -95,7 +95,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -136,12 +136,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -171,17 +171,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - 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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -193,11 +191,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -223,14 +221,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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 @@ -240,6 +235,7 @@ environments: - 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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl @@ -248,12 +244,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -266,6 +263,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -282,23 +280,22 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.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/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/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-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/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -308,9 +305,12 @@ environments: - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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 @@ -333,7 +333,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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 @@ -341,6 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl @@ -366,14 +366,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -384,7 +384,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -425,12 +425,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -460,7 +460,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_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/_openmp_mutex-4.5-7_kmp_llvm.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 @@ -477,10 +477,10 @@ environments: - 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/libcblas-3.11.0-7_hb0561ab_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.6-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/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_0.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/libgcc-15.2.0-hcbb3090_19.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_19.conda @@ -489,40 +489,38 @@ environments: - 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/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.33-openmp_he657e61_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.21-h1a92334_3.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.1-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.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/llvm-openmp-22.1.5-hc7d1edf_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.6-hc7d1edf_0.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-25.8.2-h7039424_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.2.0-h7039424_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.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.1-py314h3a4d195_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.1-py314h36abed7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.4-h4c637c5_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.5-h4c637c5_100_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_2.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-0.30.0-py314haad56a0_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.5-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-h4818236_10.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: . - 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/f1/58c14b37525dc075f3bdf149251f079723049a9f1c82eb48835a0e6b8db3/diffpy_pdffit2-1.6.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -539,9 +537,6 @@ environments: - 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/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -567,17 +562,19 @@ environments: - 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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.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 @@ -589,12 +586,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -606,6 +603,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -629,33 +627,35 @@ environments: - 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl @@ -704,15 +704,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -723,7 +723,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda @@ -762,12 +762,12 @@ environments: - 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/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -798,7 +798,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - 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-3.23.1-pyhcf101f3_0.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 @@ -808,52 +808,50 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h0ea6238_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-7_h8455456_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-7_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_0.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/libhwloc-2.13.0-default_h049141e_1000.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.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/libsodium-1.0.21-h6a83c73_3.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.1-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h692994f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-hbc0d294_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/llvm-openmp-22.1.5-h4fa8253_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.6-h4fa8253_0.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/mkl-2026.0.0-hac47afa_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_908.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-25.8.2-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.2.0-h80d1838_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.2-hf411b9b_0.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.4-h4b44e0e_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.5-h4b44e0e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314hcaaf0b2_2.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/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_2.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-0.30.0-py314h9f07db2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.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.5-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.3-h41ae7f8_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_36.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_37.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-h507cc87_10.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: . - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/53/b50773ecf1d1e4a5858ee13011e30317ba02639ae4a1411a34967951fc9b/crysfml-0.6.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -870,7 +868,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -896,7 +893,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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 @@ -906,6 +902,7 @@ environments: - 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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl @@ -916,12 +913,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-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/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl @@ -934,11 +931,12 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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 @@ -958,20 +956,17 @@ environments: - 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.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/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -982,12 +977,15 @@ environments: - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl @@ -1002,6 +1000,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl @@ -1019,6 +1018,7 @@ environments: - 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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.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/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl @@ -1053,7 +1053,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-7_h0358290_openblas.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/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.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 @@ -1064,28 +1064,28 @@ environments: - 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/libopenblas-0.3.33-pthreads_h94d23a6_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.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.1-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/libuuid-2.42-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-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/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-25.8.2-he4ff34a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.2.0-he4ff34a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.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/pyyaml-6.0.3-py312h8a5da7c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_2.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-0.30.0-py312h868fb18_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.5-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-h41580af_10.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.13.0-pyhcf101f3_0.conda @@ -1098,14 +1098,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.2.1-pyhd8ed1ab_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/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 @@ -1116,7 +1116,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -1157,7 +1157,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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 @@ -1192,16 +1192,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - pypi: https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -1213,16 +1211,15 @@ environments: - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -1243,11 +1240,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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 @@ -1257,12 +1254,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-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/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.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 @@ -1270,13 +1267,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.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/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -1288,6 +1286,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -1299,6 +1298,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/31/6cf181011dc738c33bf6ba7aea2e8e1d3c1f71b7dab1942f3054f66f6202/asteval-1.0.8-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/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl @@ -1311,10 +1311,9 @@ environments: - 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/61/3c1ea8c10bf4f6bf83c33a7f5b4a3143f4cc1f979859dec5498b6cc31900/pycifrw-5.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl @@ -1322,6 +1321,7 @@ environments: - 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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-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/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -1331,11 +1331,12 @@ environments: - 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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/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/0b/b905ae82d9419dc38123523862db64978ca2954b69609c3ae8fdaca1084c/numpy-2.4.5-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/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl @@ -1354,7 +1355,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl @@ -1386,14 +1386,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.2.1-pyhd8ed1ab_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/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 @@ -1404,7 +1404,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -1445,7 +1445,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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 @@ -1480,7 +1480,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_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/_openmp_mutex-4.5-7_kmp_llvm.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.5.0-py312h87c4bb7_0.conda @@ -1498,10 +1498,10 @@ environments: - 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/libcblas-3.11.0-7_hb0561ab_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.6-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/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_0.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/libgcc-15.2.0-hcbb3090_19.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_19.conda @@ -1509,39 +1509,37 @@ environments: - 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/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.33-openmp_he657e61_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.21-h1a92334_3.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.1-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.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/llvm-openmp-22.1.5-hc7d1edf_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.6-hc7d1edf_0.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-25.8.2-h7039424_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.2.0-h7039424_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.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.1-py312h19bbe71_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.1-py312h1de3e18_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/pyyaml-6.0.3-py312h04c11ed_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_2.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-0.30.0-py312h6ef9ec0_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.5-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-h4818236_10.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: . - pypi: https://files.pythonhosted.org/packages/01/5c/87b5fefdd3c4b157c8a16833f2236723136806814584c4589610217252f0/diffpy_pdffit2-1.6.0-cp312-cp312-macosx_11_0_arm64.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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.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/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -1549,7 +1547,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/1d/9f9e30d76300b0150afaa8b37fab9a0194d44fd4f6b1e5038aca4a1440ed/crysfml-0.6.2-cp312-cp312-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.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/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl @@ -1559,10 +1556,10 @@ environments: - 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/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.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/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 @@ -1588,19 +1585,19 @@ environments: - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/cc/ddaf3af9c46966fef5be879256f213d85a0c56c75d07a3b7defec7cf6b4c/numpy-2.4.5-cp312-cp312-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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/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/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl @@ -1609,12 +1606,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -1626,6 +1623,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -1650,10 +1648,10 @@ environments: - 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/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/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl @@ -1662,6 +1660,7 @@ environments: - 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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -1671,15 +1670,16 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_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/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl @@ -1723,15 +1723,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.2.1-pyhd8ed1ab_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/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 @@ -1742,7 +1742,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda @@ -1781,7 +1781,7 @@ environments: - 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/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_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 @@ -1817,7 +1817,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - 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-3.23.1-pyhcf101f3_0.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.5.0-py312h06d0912_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312hc6d9e41_1.conda @@ -1828,52 +1828,50 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h0ea6238_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-7_h8455456_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-7_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_0.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/libhwloc-2.13.0-default_h049141e_1000.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.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/libsodium-1.0.21-h6a83c73_3.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.1-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h692994f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-hbc0d294_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/llvm-openmp-22.1.5-h4fa8253_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.6-h4fa8253_0.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/mkl-2026.0.0-hac47afa_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_908.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-25.8.2-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.2.0-h80d1838_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.2-hf411b9b_0.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/pywin32-311-py312h829343e_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py312h829343e_2.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/pyyaml-6.0.3-py312h05f76fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_2.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-0.30.0-py312hdabe01f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.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.5-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.3-h41ae7f8_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_36.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_37.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-h507cc87_10.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: . - 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/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -1891,7 +1889,6 @@ environments: - 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/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/55/5733807f4af131ea6194309ac0f43eb5b05463c676d036ef948f3143c1f2/pycifrw-5.0.1-cp312-cp312-win_amd64.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 @@ -1899,6 +1896,7 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-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/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -1909,7 +1907,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/8b/7ec325b4e9e78beefc2d025b01ee8a2fde771ef7c957c3bff99b9e1fbffa/xraydb-4.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-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/3c/f0/cbf5d391b0b3a5e8cad264603e2fae256b0bde8ce43566b13b78faedc659/numpy-2.4.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl @@ -1919,8 +1916,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-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 @@ -1929,6 +1924,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl @@ -1939,12 +1935,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6f/0c/8297c8d978c919ad6318011631a6123082d5da940da5f8612e75a247d739/diffpy_pdffit2-1.6.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl @@ -1960,6 +1956,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/82/b54e646be7b938fcbdda10030c6533bd2bb1a59930a1381cc83d6050a49c/spglib-2.6.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -1982,19 +1979,20 @@ 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.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/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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 @@ -2005,10 +2003,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl @@ -2035,7 +2036,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl @@ -2071,7 +2071,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-7_h0358290_openblas.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/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.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 @@ -2082,27 +2082,27 @@ environments: - 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/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.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.1-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/libuuid-2.42-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-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/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-25.8.2-he4ff34a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.2.0-he4ff34a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.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.4-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.5-habeac84_100_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_2.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-0.30.0-py314h2e6c369_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.5-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-h41580af_10.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.13.0-pyhcf101f3_0.conda @@ -2116,14 +2116,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -2134,7 +2134,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -2175,12 +2175,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -2210,17 +2210,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - 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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -2232,11 +2230,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -2262,14 +2260,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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 @@ -2279,6 +2274,7 @@ environments: - 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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl @@ -2287,12 +2283,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -2305,6 +2302,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -2321,23 +2319,22 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.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/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/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-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/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -2347,9 +2344,12 @@ environments: - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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 @@ -2372,7 +2372,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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 @@ -2380,6 +2379,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl @@ -2405,14 +2405,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -2423,7 +2423,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -2464,12 +2464,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -2499,7 +2499,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_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/_openmp_mutex-4.5-7_kmp_llvm.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 @@ -2516,10 +2516,10 @@ environments: - 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/libcblas-3.11.0-7_hb0561ab_openblas.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.6-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/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_0.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/libgcc-15.2.0-hcbb3090_19.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_19.conda @@ -2528,40 +2528,38 @@ environments: - 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/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.33-openmp_he657e61_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.21-h1a92334_3.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.1-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.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/llvm-openmp-22.1.5-hc7d1edf_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.6-hc7d1edf_0.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-25.8.2-h7039424_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.2.0-h7039424_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.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.1-py314h3a4d195_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.1-py314h36abed7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.4-h4c637c5_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.5-h4c637c5_100_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_2.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-0.30.0-py314haad56a0_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.5-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-h4818236_10.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: . - 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/f1/58c14b37525dc075f3bdf149251f079723049a9f1c82eb48835a0e6b8db3/diffpy_pdffit2-1.6.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -2578,9 +2576,6 @@ environments: - 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/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -2606,17 +2601,19 @@ environments: - 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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.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 @@ -2628,12 +2625,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl @@ -2645,6 +2642,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -2668,33 +2666,35 @@ environments: - 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl @@ -2743,15 +2743,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -2762,7 +2762,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda @@ -2801,12 +2801,12 @@ environments: - 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/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -2837,7 +2837,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - 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-3.23.1-pyhcf101f3_0.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 @@ -2847,52 +2847,50 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h0ea6238_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-7_h8455456_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-7_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_0.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/libhwloc-2.13.0-default_h049141e_1000.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.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/libsodium-1.0.21-h6a83c73_3.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.1-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h692994f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-hbc0d294_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/llvm-openmp-22.1.5-h4fa8253_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.6-h4fa8253_0.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/mkl-2026.0.0-hac47afa_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_908.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-25.8.2-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_906.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.2.0-h80d1838_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.2-hf411b9b_0.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.4-h4b44e0e_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.5-h4b44e0e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314hcaaf0b2_2.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/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_2.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-0.30.0-py314h9f07db2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.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.5-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.3-h41ae7f8_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_36.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_37.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-h507cc87_10.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: . - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/53/b50773ecf1d1e4a5858ee13011e30317ba02639ae4a1411a34967951fc9b/crysfml-0.6.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl @@ -2909,7 +2907,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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 @@ -2935,7 +2932,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-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 @@ -2945,6 +2941,7 @@ environments: - 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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl @@ -2955,12 +2952,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.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/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-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/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl @@ -2973,11 +2970,12 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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 @@ -2997,20 +2995,17 @@ environments: - 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/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.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/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl @@ -3021,12 +3016,15 @@ environments: - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl @@ -3041,6 +3039,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl @@ -3058,6 +3057,7 @@ environments: - 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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.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/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl @@ -3081,31 +3081,31 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.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/libexpat-2.8.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.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/libsodium-1.0.21-h280c20c_3.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.1-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/libuuid-2.42-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-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/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.2-h35e630c_0.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.4-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.5-habeac84_100_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_2.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-0.30.0-py314h2e6c369_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.5-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-h41580af_10.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.13.0-pyhcf101f3_0.conda @@ -3119,14 +3119,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -3137,7 +3137,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -3178,12 +3178,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -3213,108 +3213,78 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz - 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/38/8b/7ec325b4e9e78beefc2d025b01ee8a2fde771ef7c957c3bff99b9e1fbffa/xraydb-4.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/91/6d/c00cb0d69d2e240c233c65b7f76d10522731156b28a2135bb97a05abc32c/easydiffraction-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/31/6cf181011dc738c33bf6ba7aea2e8e1d3c1f71b7dab1942f3054f66f6202/asteval-1.0.8-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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.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/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/bf/e1/9e8e09ab8fc5c77f228e3271188dc4f569d12692548c42bff81b1fbba5e1/easydiffraction-0.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/2c/3850985d4c64048dec7b826f8a803e135b52b11b4c81c9cd4326b1ca15ab/ncrystal_core-4.4.2-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/f5/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f7/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_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/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 osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -3330,14 +3300,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -3348,7 +3318,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda @@ -3389,12 +3359,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-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-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -3424,20 +3394,20 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - 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-3.23.1-pyhcf101f3_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/debugpy-1.8.20-py314he609de1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h385eeb1_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.6-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/libexpat-2.8.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_0.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/libsodium-1.0.21-h1a92334_3.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.1-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/markupsafe-3.0.3-py314h6e9b3f0_1.conda @@ -3447,116 +3417,86 @@ environments: - 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.1-py314h3a4d195_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.1-py314h36abed7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.4-h4c637c5_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.5-h4c637c5_100_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_2.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-0.30.0-py314haad56a0_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.5-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-h4818236_10.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/04/f1/58c14b37525dc075f3bdf149251f079723049a9f1c82eb48835a0e6b8db3/diffpy_pdffit2-1.6.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-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/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/7e/c2cfe0bdbec1f5ce2bd92e03311038e1c491dfd54824606f38a61167a3f0/crysfml-0.6.2-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/8b/7ec325b4e9e78beefc2d025b01ee8a2fde771ef7c957c3bff99b9e1fbffa/xraydb-4.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.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/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/91/6d/c00cb0d69d2e240c233c65b7f76d10522731156b28a2135bb97a05abc32c/easydiffraction-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/31/6cf181011dc738c33bf6ba7aea2e8e1d3c1f71b7dab1942f3054f66f6202/asteval-1.0.8-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/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.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/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.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/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/bf/e1/9e8e09ab8fc5c77f228e3271188dc4f569d12692548c42bff81b1fbba5e1/easydiffraction-0.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-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/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/f5/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f7/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/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 win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -3571,15 +3511,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-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.4.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-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/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.4-py314hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-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/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 @@ -3590,7 +3530,7 @@ environments: - 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 - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda @@ -3629,12 +3569,12 @@ environments: - 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/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_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.4-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_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_abi-3.14-8_cp314.conda @@ -3665,140 +3605,111 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - 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-3.23.1-pyhcf101f3_0.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/debugpy-1.8.20-py314hb98de8c_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h0ea6238_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_0.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/libsodium-1.0.21-h6a83c73_3.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.1-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/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.2-hf411b9b_0.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.4-h4b44e0e_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.5-h4b44e0e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314hcaaf0b2_2.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/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_2.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-0.30.0-py314h9f07db2_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.5-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.3-h41ae7f8_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_36.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_36.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_37.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_37.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-h507cc87_10.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/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/53/b50773ecf1d1e4a5858ee13011e30317ba02639ae4a1411a34967951fc9b/crysfml-0.6.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.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/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.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/38/8b/7ec325b4e9e78beefc2d025b01ee8a2fde771ef7c957c3bff99b9e1fbffa/xraydb-4.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.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/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-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/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - 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/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-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/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/d0/26c81ffbe588f936d05f395da34046c66322e8067c9fd331c788c4f682f2/diffpy_pdffit2-1.6.0-cp314-cp314-win_amd64.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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/91/6d/c00cb0d69d2e240c233c65b7f76d10522731156b28a2135bb97a05abc32c/easydiffraction-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/31/6cf181011dc738c33bf6ba7aea2e8e1d3c1f71b7dab1942f3054f66f6202/asteval-1.0.8-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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.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/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.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/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/bf/e1/9e8e09ab8fc5c77f228e3271188dc4f569d12692548c42bff81b1fbba5e1/easydiffraction-0.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/f5/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f7/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/1c/a28b27effb13a381fe077ea3e3e78f6debd6315f2b3edff67bbb93d0ef51/gemmi-0.7.5-cp314-cp314-win_amd64.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda @@ -4147,19 +4058,19 @@ packages: purls: [] size: 112766 timestamp: 1702146165126 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda - sha256: ea33c40977ea7a2c3658c522230058395bc2ee0d89d99f0711390b6a1ee80d12 - md5: a3b390520c563d78cc58974de95a03e5 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.conda + sha256: 363018b25fdb5534c79783d912bd4b685a3547f4fc5996357ad548899b0ee8e7 + md5: 93764a5ca80616e9c10106cdaec92f74 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 constrains: - - expat 2.8.0.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 77241 - timestamp: 1777846112704 + size: 77294 + timestamp: 1779278686680 - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 md5: a360c33a5abe61c07959e449fa1453eb @@ -4296,16 +4207,16 @@ packages: purls: [] size: 5931919 timestamp: 1776993658641 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.conda - sha256: 64e5c80cbce4680a2d25179949739a6def695d72c40ca28f010711764e372d97 - md5: 7af961ef4aa2c1136e11dd43ded245ab +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-h280c20c_1.conda + sha256: b677bbf1c339d894757c3dcfbb2f88649e499e4991d70ae09a1466da9a6c92d6 + md5: 965e4d531b588b2e42f66fd8e48b056c depends: - - libgcc >=14 - __glibc >=2.17,<3.0.a0 + - libgcc >=14 license: ISC purls: [] - size: 277661 - timestamp: 1772479381288 + size: 269272 + timestamp: 1779163468406 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda sha256: 54cdcd3214313b62c2a8ee277e6f42150d9b748264c1b70d958bf735e420ef8d md5: 7dc38adcbf71e6b38748e919e16e0dce @@ -4330,28 +4241,27 @@ packages: purls: [] size: 5852044 timestamp: 1778269036376 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda - sha256: bc1b08c92626c91500fd9f26f2c797f3eb153b627d53e9c13cd167f1e12b2829 - md5: 38ffe67b78c9d4de527be8315e5ada2c +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda + sha256: 3f0edf1280e2f6684a986f821eaa3e123d2694a00b31b96ca0d4a4c12c129231 + md5: 7d0a66598195ef00b6efc55aefc7453b depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 license: BSD-3-Clause license_family: BSD purls: [] - size: 40297 - timestamp: 1775052476770 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda - sha256: c180f4124a889ac343fc59d15558e93667d894a966ec6fdb61da1604481be26b - md5: 0f03292cc56bf91a077a134ea8747118 + size: 40163 + timestamp: 1779118517630 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b + md5: 4e33d49bf4fc853855a3b00643aa5484 depends: - - __glibc >=2.17,<3.0.a0 - libgcc >=14 + - __glibc >=2.17,<3.0.a0 license: MIT - license_family: MIT purls: [] - size: 895108 - timestamp: 1753948278280 + size: 419935 + timestamp: 1779396012261 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c md5: 5aa797f8787fe7a17d1b0821485b5adc @@ -4443,31 +4353,30 @@ packages: purls: [] size: 918956 timestamp: 1777422145199 -- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.8.2-he4ff34a_0.conda - sha256: d1a673d1418d9e956b6e4e46c23e72a511c5c1d45dc5519c947457427036d5e2 - md5: baffb1570b3918c784d4490babc52fbf +- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.2.0-he4ff34a_0.conda + sha256: 20566a8a389aaf1fe872e721295bdc0e3bd75d4c2e2f0353e7223d19c8203033 + md5: 87da3d7e0578c9fa61729846c314ac58 depends: - libgcc >=14 - - libstdcxx >=14 - __glibc >=2.28,<3.0.a0 - - libnghttp2 >=1.68.1,<2.0a0 - - libuv >=1.51.0,<2.0a0 + - libstdcxx >=14 + - libuv >=1.52.1,<2.0a0 - c-ares >=1.34.6,<2.0a0 - - openssl >=3.5.5,<4.0a0 - - libsqlite >=3.52.0,<4.0a0 + - 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 - libzlib >=1.3.2,<2.0a0 - libabseil >=20260107.1,<20260108.0a0 - libabseil * cxx17* - zstd >=1.5.7,<1.6.0a0 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 + - libsqlite >=3.53.1,<4.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - openssl >=3.5.6,<4.0a0 license: MIT - license_family: MIT purls: [] - size: 18829340 - timestamp: 1774514313036 + size: 19707853 + timestamp: 1779471099457 - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda sha256: c0ef482280e38c71a08ad6d71448194b719630345b0c9c60744a2010e8a8e0cb md5: da1b85b6a87e141f5140bb9924cecab0 @@ -4535,23 +4444,23 @@ packages: purls: [] size: 31608571 timestamp: 1772730708989 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-habeac84_100_cp314.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.5-habeac84_100_cp314.conda build_number: 100 - sha256: dec247c5badc811baa34d6085df9d0465535883cf745e22e8d79092ad54a3a7b - md5: a443f87920815d41bfe611296e507995 + sha256: 55eed9bf2a3f6e90311276f0834737fe7c2d9ec3e5e2e557507858df4c7521e6 + md5: da92e59ff92f2d5ede4f612af20f583f depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.7.5,<3.0a0 + - libexpat >=2.8.0,<3.0a0 - libffi >=3.5.2,<3.6.0a0 - libgcc >=14 - - liblzma >=5.8.2,<6.0a0 + - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.52.0,<4.0a0 - - libuuid >=2.42,<3.0a0 + - libsqlite >=3.53.1,<4.0a0 + - libuuid >=2.42.1,<3.0a0 - libzlib >=1.3.2,<2.0a0 - - ncurses >=6.5,<7.0a0 + - ncurses >=6.6,<7.0a0 - openssl >=3.5.6,<4.0a0 - python_abi 3.14.* *_cp314 - readline >=8.3,<9.0a0 @@ -4560,8 +4469,8 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: Python-2.0 purls: [] - size: 36705460 - timestamp: 1775614357822 + size: 36745188 + timestamp: 1779236923603 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 @@ -4593,24 +4502,23 @@ packages: - pkg:pypi/pyyaml?source=hash-mapping size: 202391 timestamp: 1770223462836 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_2.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda noarch: python - sha256: be66c1f85c3b48137200d62c12d918f4f8ad329423daef04fed292818efd3c28 - md5: 082985717303dab433c976986c674b35 + sha256: 970b2a1d12983d8d1cc05d914ad88a0b6ef1fa14038c9649aa834dd6ebee65d7 + md5: acd216255e1370e9aeab5351b831f07c depends: - python - libgcc >=14 - libstdcxx >=14 - __glibc >=2.17,<3.0.a0 - - zeromq >=4.3.5,<4.4.0a0 - _python_abi3_support 1.* - cpython >=3.12 + - zeromq >=4.3.5,<4.4.0a0 license: BSD-3-Clause - license_family: BSD purls: - pkg:pypi/pyzmq?source=hash-mapping - size: 211567 - timestamp: 1771716961404 + size: 210896 + timestamp: 1779483879367 - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 md5: d7d95fc8287ea7bf33e0e7116d2b95ec @@ -4708,20 +4616,20 @@ packages: purls: [] size: 85189 timestamp: 1753484064210 -- conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda - sha256: 325d370b28e2b9cc1f765c5b4cdb394c91a5d958fbd15da1a14607a28fee09f6 - md5: 755b096086851e1193f3b10347415d7c +- conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h09e67af_11.conda + sha256: dc9f28dedcb5f35a127fad2d847674d2833369dd616d294e423b8997df31d8a8 + md5: 96b08867e21d4694fa5c2c226e6581b0 depends: - libgcc >=14 - __glibc >=2.17,<3.0.a0 - libstdcxx >=14 - krb5 >=1.22.2,<1.23.0a0 - - libsodium >=1.0.21,<1.0.22.0a0 + - libsodium >=1.0.22,<1.0.23.0a0 license: MPL-2.0 license_family: MOZILLA purls: [] - size: 311150 - timestamp: 1772476812121 + size: 311184 + timestamp: 1779123989774 - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 @@ -4903,24 +4811,24 @@ packages: purls: [] size: 4409 timestamp: 1770719370682 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda - sha256: 6f4ff81534c19e76acf52fcabf4a258088a932b8f1ac56e9a59e98f6051f8e46 - md5: 56fb2c6c73efc627b40c77d14caecfba +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-h4c7d964_0.conda + sha256: 86981d764e4ea1883409d30447ff9da46127426d31a63df08315aaded768e652 + md5: c9b86eece2f944541b86441c94117ab3 depends: - __win license: ISC purls: [] - size: 131388 - timestamp: 1776865633471 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda - sha256: c9dbcc8039a52023660d6d1bbf87594a93dd69c6ac5a2a44323af2c92976728d - md5: e18ad67cf881dcadee8b8d9e2f8e5f73 + size: 130182 + timestamp: 1779289939595 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + sha256: 9812a303a1395e1dafbd92e5bc8a1ff6013bcbba0a09c7f03a8d23e43560aa9b + md5: 489b8e97e666c93f68fdb35c3c9b957f depends: - __unix license: ISC purls: [] - size: 131039 - timestamp: 1776865545798 + size: 129868 + timestamp: 1779289852439 - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 noarch: python sha256: 561e6660f26c35d137ee150187d89767c988413c978e1b712d53f27ddf70ea17 @@ -4943,16 +4851,16 @@ packages: - pkg:pypi/cached-property?source=hash-mapping size: 11065 timestamp: 1615209567874 -- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda - sha256: 989db6e5957c4b44fa600c68c681ec2f36a55e48f7c7f1c073d5e91caa8cd878 - md5: 929471569c93acefb30282a22060dcd5 +- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda + sha256: 645655a3510e38e625da136595f3f16f2130c3263630cc3bc8f60f619ddbe490 + md5: 9fefff2f745ea1cc2ef15211a20c054a depends: - python >=3.10 license: ISC purls: - - pkg:pypi/certifi?source=hash-mapping - size: 135656 - timestamp: 1776866680878 + - pkg:pypi/certifi?source=compressed-mapping + size: 134201 + timestamp: 1779285131141 - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda sha256: 3f9483d62ce24ecd063f8a5a714448445dc8d9e201147c46699fc0033e824457 md5: a9167b9571f3baa9d448faa2139d1089 @@ -4998,28 +4906,28 @@ packages: purls: [] size: 46463 timestamp: 1772728929620 -- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-py314hd8ed1ab_100.conda noarch: generic - sha256: 40dc224f2b718e5f034efd2332bc315a719063235f63673468d26a24770094ee - md5: f111d4cfaf1fe9496f386bc98ae94452 + sha256: 777882d2685f368417f31bbe1b28f73687fc6c8f6a5768bda20ffeefa6b07f5b + md5: a749029ce5d0632a913db19d17f944ab depends: - python >=3.14,<3.15.0a0 - python_abi * *_cp314 license: Python-2.0 purls: [] - size: 49809 - timestamp: 1775614256655 -- conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - sha256: c17c6b9937c08ad63cb20a26f403a3234088e57d4455600974a0ce865cb14017 - md5: 9ce473d1d1be1cc3810856a48b3fab32 + size: 50212 + timestamp: 1779236682725 +- conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.conda + sha256: 430bd9d731b265f0bedb3183ac3ecfaa1656390c092b6e864ff8cc1229843c8c + md5: 61dcf784d59ef0bd62c57d982b154ace depends: - - python >=3.9 + - python >=3.10 license: BSD-2-Clause license_family: BSD purls: - - pkg:pypi/decorator?source=hash-mapping - size: 14129 - timestamp: 1740385067843 + - pkg:pypi/decorator?source=compressed-mapping + size: 16102 + timestamp: 1779115228886 - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 sha256: 9717a059677553562a8f38ff07f3b9f61727bd614f505658b0a5ecbcf8df89be md5: 961b3a227b437d82ad7054484cfa71b2 @@ -5146,18 +5054,18 @@ packages: - pkg:pypi/hyperframe?source=hash-mapping size: 17397 timestamp: 1737618427549 -- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda - sha256: 9ab620e6f64bb67737bd7bc1ad6f480770124e304c6710617aba7fe60b089f48 - md5: fb7130c190f9b4ec91219840a05ba3ac +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda + sha256: 3d25f9f6f7ab3e1ce6429fc8c8aae0335cf446692e715068488536d220cc43de + md5: 1b9083b7f00609605d1483dbc6071a81 depends: - python >=3.10 - python license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/idna?source=hash-mapping - size: 59038 - timestamp: 1776947141407 + - pkg:pypi/idna?source=compressed-mapping + size: 62642 + timestamp: 1779294335905 - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda sha256: 82ab2a0d91ca1e7e63ab6a4939356667ef683905dea631bc2121aa534d347b16 md5: 080594bf4493e6bae2607e65390c520a @@ -5860,18 +5768,18 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - sha256: 79db7928d13fab2d892592223d7570f5061c192f27b9febd1a418427b719acc6 - md5: 12c566707c80111f9799308d9e265aef +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + sha256: e27e0473fc6723311a0bd48b89b616fa1b996a2f7a2b555338cbbcfb9c640568 + md5: 9c5491066224083c41b6d5635ed7107b depends: - - python >=3.9 + - python >=3.10 - python license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pycparser?source=hash-mapping - size: 110100 - timestamp: 1733195786147 + - pkg:pypi/pycparser?source=compressed-mapping + size: 55886 + timestamp: 1779293633166 - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 md5: 16c18772b340887160c79a6acc022db0 @@ -5943,16 +5851,16 @@ packages: purls: [] size: 46449 timestamp: 1772728979370 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.4-h4df99d1_100.conda - sha256: 36ff7984e4565c85149e64f8206303d412a0652e55cf806dcb856903fa056314 - md5: e4e60721757979d01d3964122f674959 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda + sha256: 41dd7da285d71d519257fa7dacb1cae060d5ebfaa5f92cba5994899d2978e943 + md5: 41954747ba952ec4b01e16c2c9e8d8ff depends: - - cpython 3.14.4.* + - cpython 3.14.5.* - python_abi * *_cp314 license: Python-2.0 purls: [] - size: 49806 - timestamp: 1775614307464 + size: 50212 + timestamp: 1779236703009 - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_0.conda sha256: 1c55116c22512cef7b01d55ae49697707f2c1fd829407183c19817e2d300fd8d md5: 1cd2f3e885162ee1366312bd1b1677fd @@ -6026,6 +5934,7 @@ packages: constrains: - chardet >=3.0.2,<8 license: Apache-2.0 + license_family: APACHE purls: - pkg:pypi/requests?source=compressed-mapping size: 68709 @@ -6366,18 +6275,18 @@ packages: - pkg:pypi/win-inet-pton?source=hash-mapping size: 9555 timestamp: 1733130678956 -- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.1-pyhcf101f3_0.conda - sha256: 523616c0530d305d2216c2b4a8dfd3872628b60083255b89c5e0d8c42e738cca - md5: e1c36c6121a7c9c76f2f148f1e83b983 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 + md5: ba3dcdc8584155c97c648ae9c044b7a3 depends: - python >=3.10 - python license: MIT license_family: MIT purls: - - pkg:pypi/zipp?source=hash-mapping - size: 24461 - timestamp: 1776131454755 + - pkg:pypi/zipp?source=compressed-mapping + size: 24190 + timestamp: 1779159948016 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda build_number: 7 sha256: 7acaa2e0782cad032bdaf756b536874346ac1375745fb250e9bdd6a48a7ab3cd @@ -6663,16 +6572,16 @@ packages: purls: [] size: 18810 timestamp: 1778489991330 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda - sha256: dddd01bd6b338221342a89530a1caffe6051a70cc8f8b1d8bb591d5447a3c603 - md5: ff484b683fecf1e875dfc7aa01d19796 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.6-h55c6f16_0.conda + sha256: 3e2f8ad32ddab88c5114b9aa2160f8c129f515df0e551d0d86ef5744446afdbd + md5: 589cc6f6222fdc0eaf8e90bc38fcce7b depends: - __osx >=11.0 license: Apache-2.0 WITH LLVM-exception license_family: Apache purls: [] - size: 569359 - timestamp: 1778191546305 + size: 570038 + timestamp: 1779253025527 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 md5: 44083d2d2c2025afca315c7a172eab2b @@ -6693,18 +6602,18 @@ packages: purls: [] size: 107458 timestamp: 1702146414478 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.0-hf6b4638_0.conda - sha256: f4b1cafc59afaede8fa0a2d9cf376840f1c553001acd72f6ead18bbc8ac8c49c - md5: 65466e82c09e888ca7560c11a97d5450 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_0.conda + sha256: 3133fb6bfa871288b92c8b8752696686a841bf4ffe035aa3038033c9e15b738e + md5: ef22e9ab1dc7c2f334252f565f90b3b8 depends: - __osx >=11.0 constrains: - - expat 2.8.0.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 68789 - timestamp: 1777846180142 + size: 69110 + timestamp: 1779278728511 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 md5: 43c04d9cb46ef176bb2a4c77e324d599 @@ -6804,15 +6713,15 @@ packages: purls: [] size: 4304965 timestamp: 1776995497368 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.21-h1a92334_3.conda - sha256: df603472ea1ebd8e7d4fb71e4360fe48d10b11c240df51c129de1da2ff9e8227 - md5: 7cc5247987e6d115134ebab15186bc13 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h1a92334_1.conda + sha256: 202be45db5726757a8ea1f374f85aacc18c504f5ff15b2558496dff4c8779c48 + md5: 9ed5ab909c449bdcae72322e44875a18 depends: - __osx >=11.0 license: ISC purls: [] - size: 248039 - timestamp: 1772479570912 + size: 247352 + timestamp: 1779164136206 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.1-h1b79a29_0.conda sha256: 49daec7c83e70d4efc17b813547824bc2bcf2f7256d84061d24fbfe537da9f74 md5: 6681822ea9d362953206352371b6a904 @@ -6823,16 +6732,15 @@ packages: purls: [] size: 920047 timestamp: 1777987051643 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda - sha256: 042c7488ad97a5629ec0a991a8b2a3345599401ecc75ad6a5af73b60e6db9689 - md5: c0d87c3c8e075daf1daf6c31b53e8083 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda + sha256: e23176af832f637693ebbb9bbe7d29c0f4cba662dabd001081d2aa6fc9f7f661 + md5: fa9fef7d9f33724b7c3899c883c25a3e depends: - __osx >=11.0 license: MIT - license_family: MIT purls: [] - size: 421195 - timestamp: 1753948426421 + size: 122732 + timestamp: 1779396113397 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 md5: bc5a5721b6439f2f62a84f2548136082 @@ -6845,19 +6753,19 @@ packages: purls: [] size: 47759 timestamp: 1774072956767 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.5-hc7d1edf_1.conda - sha256: 2cd49562feda2bf324651050b2b035080fd635ed0f1c96c9ce7a59eff3cc0029 - md5: 8a4e2a54034b35bc6fa5bf9282913f45 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.6-hc7d1edf_0.conda + sha256: 12d3652549a9abd30f3cc14797715327b86e91001d11865106eb3e02c40be9da + md5: b8cf70b77b2ed10d5f82e367c327b76b depends: - __osx >=11.0 constrains: - - openmp 22.1.5|22.1.5.* + - openmp 22.1.6|22.1.6.* - intel-openmp <0.0a0 license: Apache-2.0 WITH LLVM-exception license_family: APACHE purls: [] - size: 285806 - timestamp: 1778447786965 + size: 284850 + timestamp: 1779340584016 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py312h04c11ed_1.conda sha256: 330394fb9140995b29ae215a19fad46fcc6691bdd1b7654513d55a19aaa091c1 md5: 11d95ab83ef0a82cc2de12c1e0b47fe4 @@ -6927,30 +6835,29 @@ packages: purls: [] size: 805509 timestamp: 1777423252320 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.8.2-h7039424_0.conda - sha256: 4782b172b3b8a557b60bf5f591821cf100e2092ba7a5494ce047dfa41626de26 - md5: ca8277c52fdface8bb8ebff7cd9a6f56 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.2.0-h7039424_0.conda + sha256: 0aab08fcfd0ede22286541b2e81fdbf7bbf9c79a29701a823268e336c4f55b55 + md5: 340cb3c8e5110bcbeace79a22f12d1ab depends: - - libcxx >=19 - __osx >=11.0 - - icu >=78.3,<79.0a0 + - libcxx >=19 + - c-ares >=1.34.6,<2.0a0 + - libuv >=1.52.1,<2.0a0 - libbrotlicommon >=1.2.0,<1.3.0a0 - libbrotlienc >=1.2.0,<1.3.0a0 - libbrotlidec >=1.2.0,<1.3.0a0 - - libnghttp2 >=1.68.1,<2.0a0 - - libuv >=1.51.0,<2.0a0 - - libsqlite >=3.52.0,<4.0a0 - - libzlib >=1.3.2,<2.0a0 - - openssl >=3.5.5,<4.0a0 - zstd >=1.5.7,<1.6.0a0 - - c-ares >=1.34.6,<2.0a0 + - openssl >=3.5.6,<4.0a0 - libabseil >=20260107.1,<20260108.0a0 - libabseil * cxx17* + - libnghttp2 >=1.68.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - icu >=78.3,<79.0a0 + - libsqlite >=3.53.1,<4.0a0 license: MIT - license_family: MIT purls: [] - size: 17101803 - timestamp: 1774517834028 + size: 17981016 + timestamp: 1779471179908 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.conda sha256: c91bf510c130a1ea1b6ff023e28bac0ccaef869446acd805e2016f69ebdc49ea md5: 25dcccd4f80f1638428613e0d7c9b4e1 @@ -7076,20 +6983,20 @@ packages: purls: [] size: 12127424 timestamp: 1772730755512 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.4-h4c637c5_100_cp314.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.5-h4c637c5_100_cp314.conda build_number: 100 - sha256: 27e7d6cbe021f37244b643f06a98e46767255f7c2907108dd3736f042757ddad - md5: e1bc5a3015a4bbeb304706dba5a32b7f + sha256: 06dec0e2f50e2f7e6a8808fcb4aff23729a3f23bcb1fca4fcbc3a341d9e38a83 + md5: f7331c9deaf21c79e5675e72b21d570b depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.5,<3.0a0 + - libexpat >=2.8.0,<3.0a0 - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 + - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.52.0,<4.0a0 + - libsqlite >=3.53.1,<4.0a0 - libzlib >=1.3.2,<2.0a0 - - ncurses >=6.5,<7.0a0 + - ncurses >=6.6,<7.0a0 - openssl >=3.5.6,<4.0a0 - python_abi 3.14.* *_cp314 - readline >=8.3,<9.0a0 @@ -7098,8 +7005,8 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: Python-2.0 purls: [] - size: 13533346 - timestamp: 1775616188373 + size: 13560854 + timestamp: 1779238292621 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 @@ -7131,23 +7038,22 @@ packages: - pkg:pypi/pyyaml?source=hash-mapping size: 189475 timestamp: 1770223788648 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_2.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_3.conda noarch: python - sha256: 2f31f799a46ed75518fae0be75ecc8a1b84360dbfd55096bc2fe8bd9c797e772 - md5: 2f6b79700452ef1e91f45a99ab8ffe5a + sha256: 086cc67ec57afb7c9c09b5e09e7356b536b5b1af6c2e97117dc022cd22f0d472 + md5: 73f22bde4991f30ae2bfac3811577c15 depends: - python - libcxx >=19 - __osx >=11.0 + - zeromq >=4.3.5,<4.4.0a0 - _python_abi3_support 1.* - cpython >=3.12 - - zeromq >=4.3.5,<4.4.0a0 license: BSD-3-Clause - license_family: BSD purls: - - pkg:pypi/pyzmq?source=hash-mapping - size: 191641 - timestamp: 1771717073430 + - pkg:pypi/pyzmq?source=compressed-mapping + size: 191432 + timestamp: 1779484184540 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 md5: f8381319127120ce51e081dce4865cf4 @@ -7240,19 +7146,19 @@ packages: purls: [] size: 83386 timestamp: 1753484079473 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h4818236_10.conda - sha256: 2705360c72d4db8de34291493379ffd13b09fd594d0af20c9eefa8a3f060d868 - md5: e85dcd3bde2b10081cdcaeae15797506 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h10816f8_11.conda + sha256: 01fd50d2801b23b59fafea6bf704a6c5faf0f5969104400eae0e6572cb2e5304 + md5: d31c0e54c4f9c51100ec8c812ee925d1 depends: - - __osx >=11.0 - libcxx >=19 + - __osx >=11.0 - krb5 >=1.22.2,<1.23.0a0 - - libsodium >=1.0.21,<1.0.22.0a0 + - libsodium >=1.0.22,<1.0.23.0a0 license: MPL-2.0 license_family: MOZILLA purls: [] - size: 245246 - timestamp: 1772476886668 + size: 245404 + timestamp: 1779124076307 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 md5: ab136e4c34e97f34fb621d2592a393d8 @@ -7477,20 +7383,20 @@ packages: purls: [] size: 68594 timestamp: 1778490364980 -- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda - sha256: 2d81d647c1f01108803457cac999b947456f44dd0a3c2325395677feacaeca67 - md5: 264e350e035092b5135a2147c238aec4 +- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_0.conda + sha256: a65e518c20d1482182bc0f1f6dd5d992f25ca44c3b32307be39ae8310db8f060 + md5: 23eb9474a16d4b9f6f27429989e82002 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - expat 2.8.0.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 71094 - timestamp: 1777846223617 + size: 71280 + timestamp: 1779278786150 - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 md5: 720b39f5ec0610457b725eb3f396219a @@ -7554,17 +7460,17 @@ packages: purls: [] size: 89411 timestamp: 1769482314283 -- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.21-h6a83c73_3.conda - sha256: d915f4fa8ebbf237c7a6e511ed458f2cfdc7c76843a924740318a15d0dd33d6d - md5: da2aa614d16a795b3007b6f4a1318a81 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda + sha256: de45b71224da77a1c3a7dd48d8885eb957c9f05455d4f0828463293e7144330f + md5: 7d5abf7ca1bd00b43d273f44d93d05dc depends: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 license: ISC purls: [] - size: 276860 - timestamp: 1772479407566 + size: 280234 + timestamp: 1779164124739 - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.1-hf5d6505_0.conda sha256: e70562450332ca8954bc16f3455468cca5ef3695c7d7187ecc87f8fc3c70e9eb md5: 7fea434a17c323256acc510a041b80d7 @@ -7638,21 +7544,21 @@ packages: purls: [] size: 58347 timestamp: 1774072851498 -- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.5-h4fa8253_1.conda - sha256: 7179e0266125c3333a097b399d0383734ee6c55fbadf332b447237a596e9698f - md5: bffe599d0eb2e78a32872712178e639c +- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.6-h4fa8253_0.conda + sha256: b12aa9c957fadf488888aa4cad6d424d499ffcceefe5d8e9077c4da46308f26b + md5: 1966432ddb4d5e13890dae3758a112d3 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - openmp 22.1.5|22.1.5.* + - openmp 22.1.6|22.1.6.* - intel-openmp <0.0a0 license: Apache-2.0 WITH LLVM-exception license_family: APACHE purls: [] - size: 347493 - timestamp: 1778448334890 + size: 347116 + timestamp: 1779341186510 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda sha256: b744287a780211ac4595126ef96a44309c791f155d4724021ef99092bae4aace md5: a73298d225c7852f97403ca105d10a13 @@ -7687,12 +7593,12 @@ packages: - pkg:pypi/markupsafe?source=hash-mapping size: 30022 timestamp: 1772445159549 -- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_906.conda - sha256: 5d6c0c02588a655aaaced67f25d1967810830d4336865e319f32cfb41d08de06 - md5: fada5d30be6e95c74ffc528f70268f02 +- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_908.conda + sha256: f997bfc9bc4d4e14261cdcd1ad195d64a72ee44dca3145d24c1349f8d1311aa5 + md5: 36ea6e1292e9d5e89374201da79646ef depends: - llvm-openmp >=22.1.5 - - onemkl-license 2026.0.0 h57928b3_906 + - onemkl-license 2026.0.0 h57928b3_908 - tbb >=2023.0.0 - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -7700,8 +7606,8 @@ packages: license: LicenseRef-IntelSimplifiedSoftwareOct2022 license_family: Proprietary purls: [] - size: 114608976 - timestamp: 1778776186500 + size: 114354729 + timestamp: 1779293121860 - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py312he06e257_0.conda sha256: 003de3343b481937b5eb500ecdbfc882e87cea608be3741dc1fb13d22f8ed95e md5: 1f32f4f6aa595377a7e651e67ba53d30 @@ -7732,22 +7638,21 @@ packages: - pkg:pypi/msgspec?source=hash-mapping size: 201836 timestamp: 1776337750218 -- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.8.2-h80d1838_0.conda - sha256: 5e38e51da1aa4bc352db9b4cec1c3e25811de0f4408edaa24e009a64de6dbfdf - md5: e626ee7934e4b7cb21ce6b721cff8677 +- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.2.0-h80d1838_0.conda + sha256: 0fad158aaffdb78d3a386e9e078e9cf17f27614750ab5e148d47867bf7c3ee91 + md5: d9b8ee334a3a6285cfc991c80edb3e13 license: MIT - license_family: MIT purls: [] - size: 31271315 - timestamp: 1774517904472 -- conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_906.conda - sha256: 2c62b4b31da810043a47014a410c546015fcc17f39d8929ba989b2f0086dc71f - md5: 331614e966c27e5ec2a9715c9d17e9a0 + size: 32513682 + timestamp: 1779471184734 +- conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda + sha256: 42ad15cbb3bf31830efa04d4b86dd2d5c0dd590c86f98adcd3c8c1f75acf5dd5 + md5: 9c9303e08b50e09f5c23e1dac99d0936 license: LicenseRef-IntelSimplifiedSoftwareOct2022 license_family: Proprietary purls: [] - size: 41154 - timestamp: 1778775952813 + size: 41580 + timestamp: 1779292867015 - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.2-hf411b9b_0.conda sha256: feb5815125c60f2be4a411e532db1ed1cd2d7261a6a43c54cb6ae90724e2e154 md5: 05c7d624cff49dbd8db1ad5ba537a8a3 @@ -7813,17 +7718,17 @@ packages: purls: [] size: 15840187 timestamp: 1772728877265 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.4-h4b44e0e_100_cp314.conda +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.5-h4b44e0e_100_cp314.conda build_number: 100 - sha256: e258d626b0ba778abb319f128de4c1211306fe86fe0803166817b1ce2514c920 - md5: 40b6a8f438afb5e7b314cc5c4a43cd84 + sha256: c561d171e5d1f1bb1a83ca6fa6aa49577a2956a245c5040dfaf8ca20c10a096e + md5: 3f76bc298eebc1ec1497852f4d7f09d9 depends: - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.5,<3.0a0 + - libexpat >=2.8.0,<3.0a0 - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 + - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.52.0,<4.0a0 + - libsqlite >=3.53.1,<4.0a0 - libzlib >=1.3.2,<2.0a0 - openssl >=3.5.6,<4.0a0 - python_abi 3.14.* *_cp314 @@ -7835,45 +7740,39 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: Python-2.0 purls: [] - size: 18055445 - timestamp: 1775615317758 + size: 18375338 + timestamp: 1779237800732 python_site_packages_path: Lib/site-packages -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py312h829343e_1.conda - sha256: a7505522048dad63940d06623f07eb357b9b65510a8d23ff32b99add05aac3a1 - md5: 64cbe4ecbebe185a2261d3f298a60cde +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py312h829343e_2.conda + sha256: 1d33c24d5590e1061e6320fbc90f395f2d14a6b3b6a4d4b3035c7c99238f6de2 + md5: 30b76957c7960ba1610ea759691b531c depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - python_abi 3.12.* *_cp312 license: PSF-2.0 license_family: PSF purls: - pkg:pypi/pywin32?source=hash-mapping - size: 6684490 - timestamp: 1756487136116 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda - sha256: 6918a8067f296f3c65d43e84558170c9e6c3f4dd735cfe041af41a7fdba7b171 - md5: 2d7b7ba21e8a8ced0eca553d4d53f773 + size: 6686746 + timestamp: 1779222945272 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314hcaaf0b2_2.conda + sha256: 695a42ed8597df226b32bdca6a90df7c188b99820388c9faf77d5e23bf595738 + md5: 7e2c2a44161e600982eb1b7437125396 depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - python_abi 3.14.* *_cp314 license: PSF-2.0 license_family: PSF purls: - - pkg:pypi/pywin32?source=hash-mapping - size: 6713155 - timestamp: 1756487145487 + - pkg:pypi/pywin32?source=compressed-mapping + size: 6713587 + timestamp: 1779222949650 - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py312h275cf98_1.conda sha256: 61cc6c2c712ab4d2b8e7a73d884ef8d3262cb80cc93a4aa074e8b08aa7ddd648 md5: 66255d136bd0daa41713a334db41d9f0 @@ -7938,24 +7837,23 @@ packages: - pkg:pypi/pyyaml?source=hash-mapping size: 181257 timestamp: 1770223460931 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_2.conda +- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda noarch: python - sha256: d84bcc19a945ca03d1fd794be3e9896ab6afc9f691d58d9c2da514abe584d4df - md5: eb1ec67a70b4d479f7dd76e6c8fe7575 + sha256: d7e65c44ea8a92f80cc0e424b4b7dbe63b8a9ec04ea774b7d4f7aed4c34cce4c + md5: ebbda9a4e5161d6e1f98146ad057dc10 depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - zeromq >=4.3.5,<4.3.6.0a0 - _python_abi3_support 1.* - cpython >=3.12 + - zeromq >=4.3.5,<4.3.6.0a0 license: BSD-3-Clause - license_family: BSD purls: - - pkg:pypi/pyzmq?source=hash-mapping - size: 183235 - timestamp: 1771716967192 + - pkg:pypi/pyzmq?source=compressed-mapping + size: 182831 + timestamp: 1779483925948 - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py312hdabe01f_0.conda sha256: faad05e6df2fc15e3ae06fdd71a36e17ff25364777aa4c40f2ec588740d64091 md5: 2c51baeda0a355b0a5e7b6acb28cf02d @@ -7995,6 +7893,7 @@ packages: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 license: Apache-2.0 + license_family: APACHE purls: [] size: 156515 timestamp: 1778673901757 @@ -8050,43 +7949,43 @@ packages: purls: [] size: 694692 timestamp: 1756385147981 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_36.conda - sha256: 7c86d8ed3ac473c3e4dde0dd05aeb1f3189a26ad66c0e250f6cf4018e73358f2 - md5: 3466ff4a8753003eeb173f508d3d5a49 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_37.conda + sha256: 67397a65e42537e9635f2be59f13437b1876ece09ce200b09deec81f186db98e + md5: 3dbe647b692777a9aecab9832004d147 depends: - - vc14_runtime >=14.44.35208 + - vc14_runtime >=14.51.36231 track_features: - vc14 license: BSD-3-Clause license_family: BSD purls: [] - size: 19989 - timestamp: 1778688080106 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_36.conda - sha256: 902984f2282859a76d764d80d74f873df7c7749117cfac15c5106e086fb2b772 - md5: 65f5c81f2796961fcfd808eee8e73596 + size: 20372 + timestamp: 1779261134447 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_37.conda + sha256: 4e83cc4e8c5513b5a0f174d9b0fe8f0ddc6adc71b28a289fe731415aef98c3e0 + md5: 96433009c79679ac14eed33479742be7 depends: - ucrt >=10.0.20348.0 - - vcomp14 14.44.35208 h818238b_36 + - vcomp14 14.51.36231 h1b9f54f_37 constrains: - - vs2015_runtime 14.44.35208.* *_36 + - vs2015_runtime 14.51.36231.* *_37 license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime license_family: Proprietary purls: [] - size: 683790 - timestamp: 1778688078434 -- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_36.conda - sha256: 0cd5b905ab2b5e9fcb170fe8801b64917effef8e3a73ffd9b2cc4c3ee387f09c - md5: 4aa1884260877bd57d16070d20271e2d + size: 742016 + timestamp: 1779261130367 +- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_37.conda + sha256: fec2c325c85505f3957f0bdb130418a09623bcaa9286239b15f8572a00d876a8 + md5: 776acae29085df3ad5f3123888ac7c1d depends: - ucrt >=10.0.20348.0 constrains: - - vs2015_runtime 14.44.35208.* *_36 + - vs2015_runtime 14.51.36231.* *_37 license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime license_family: Proprietary purls: [] - size: 115995 - timestamp: 1778688058077 + size: 124184 + timestamp: 1779261112360 - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 sha256: 9df10c5b607dd30e05ba08cbd940009305c75db242476f4e845ea06008b0a283 md5: 1cee351bf20b830d991dbe0bc8cd7dfe @@ -8109,20 +8008,20 @@ packages: purls: [] size: 63944 timestamp: 1753484092156 -- conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h507cc87_10.conda - sha256: b8568dfde46edf3455458912ea6ffb760e4456db8230a0cf34ecbc557d3c275f - md5: 1ab0237036bfb14e923d6107473b0021 +- conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda + sha256: c3e279cb309b153152fcdd6ee6d039ad996d563c849f06be39d85b8e3351df25 + md5: f016c0c5f9c01549b259146614786192 depends: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - libsodium >=1.0.21,<1.0.22.0a0 + - libsodium >=1.0.22,<1.0.23.0a0 - krb5 >=1.22.2,<1.23.0a0 license: MPL-2.0 license_family: MOZILLA purls: [] - size: 265665 - timestamp: 1772476832995 + size: 265717 + timestamp: 1779124031378 - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda sha256: 368d8628424966fd8f9c8018326a9c779e06913dd39e646cf331226acc90e5b2 md5: 053b84beec00b71ea8ff7a4f84b55207 @@ -8318,20 +8217,6 @@ packages: version: 0.7.5 sha256: 5144f107f2bca479d1b8266a79649bd631ee92c5b1319b27b0279157331ebc89 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl - name: python-socketio - version: 5.16.1 - sha256: a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35 - requires_dist: - - bidict>=0.21.0 - - python-engineio>=4.11.0 - - 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/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl name: fonttools version: 4.63.0 @@ -8516,11 +8401,6 @@ packages: - nodejs ; extra == 'all' - pythreejs ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl - name: aiohappyeyeballs - version: 2.6.1 - sha256: f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8 - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl name: pyparsing version: 3.3.2 @@ -8636,15 +8516,6 @@ packages: requires_dist: - numpy requires_python: '>=3.11,<3.15' -- pypi: https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl - name: yarl - version: 1.23.0 - sha256: 13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl name: pydantic-core version: 2.46.4 @@ -8782,6 +8653,11 @@ packages: - objgraph>=1.7.2 ; extra == 'graph' - gprof2dot>=2022.7.29 ; extra == 'profile' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.15.14 + sha256: 715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl name: annotated-doc version: 0.0.4 @@ -8929,26 +8805,6 @@ packages: - pyyaml==5.1 ; extra == 'min-versions' - watchdog==2.0 ; extra == 'min-versions' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl - name: yarl - version: 1.23.0 - sha256: 23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl - name: numpy - version: 2.4.5 - sha256: 144fcc5a3a17679b2b82543b4a2d8dd29937230a7af13232b5f753872feb6361 - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.py3-none-any.whl - name: py3dmol - version: 2.5.4 - sha256: 32806726b5310524a2b5bfee320737f7feef635cafc945c991062806daa9e43a - requires_dist: - - ipython ; extra == 'ipython' - 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 @@ -9037,6 +8893,15 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups' 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 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl name: pooch version: 1.9.0 @@ -9061,17 +8926,6 @@ packages: version: 1.8.0 sha256: f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - name: greenlet - version: 3.5.0 - sha256: 9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c - requires_dist: - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - - objgraph ; extra == 'test' - - psutil ; extra == 'test' - - setuptools ; extra == 'test' - requires_python: '>=3.10' - 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 @@ -9214,6 +9068,17 @@ packages: - h5netcdf[h5py] ; extra == 'test' - kaleido ; extra == 'test' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl + name: greenlet + version: 3.5.1 + sha256: 3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b + requires_dist: + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' + - objgraph ; extra == 'test' + - psutil ; extra == 'test' + - setuptools ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl name: mkdocstrings-python version: 2.0.3 @@ -9533,11 +9398,6 @@ packages: requires_dist: - prompt-toolkit>=2.0,<4.0 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3c/f0/cbf5d391b0b3a5e8cad264603e2fae256b0bde8ce43566b13b78faedc659/numpy-2.4.5-cp312-cp312-win_amd64.whl - name: numpy - version: 2.4.5 - sha256: 3333dba6a4e611d666f69e177ba8fe4140366ff681a5feb2374d3fd4fff3acb6 - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl name: h5py version: 3.16.0 @@ -9596,13 +9456,24 @@ packages: - rich>=13.8.0 - annotated-doc>=0.0.2 requires_python: '>=3.10' -- 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 - sha256: e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 +- pypi: https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: greenlet + version: 3.5.1 + sha256: add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b requires_dist: - - typing-extensions>=4.14.1 - requires_python: '>=3.9' + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' + - objgraph ; extra == 'test' + - psutil ; extra == 'test' + - setuptools ; extra == 'test' + requires_python: '>=3.10' +- 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 + sha256: e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl name: matplotlib version: 3.10.9 @@ -9622,15 +9493,6 @@ packages: - setuptools-scm>=7,<10 ; extra == 'dev' - setuptools>=64 ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: yarl - version: 1.23.0 - sha256: 1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-py3-none-any.whl name: cyclebane version: 24.10.0 @@ -9638,11 +9500,6 @@ packages: requires_dist: - networkx requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: numpy - version: 2.4.5 - sha256: 7341b08ff8124d7353939778e2707b8732d03c78c1c30e0815aba2dacbe1245a - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl name: mpmath version: 1.3.0 @@ -9785,11 +9642,6 @@ packages: - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - sqlcipher3-binary ; extra == 'sqlcipher' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/47/cc/ddaf3af9c46966fef5be879256f213d85a0c56c75d07a3b7defec7cf6b4c/numpy-2.4.5-cp312-cp312-macosx_14_0_arm64.whl - name: numpy - version: 2.4.5 - sha256: 4f5bc96d35d94e4ceab8b38a92241b4611e95dc44e63b9f1fa2a331858ee3507 - requires_python: '>=3.11' - 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 @@ -9909,95 +9761,6 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/4c/65/b4b86e5fa07543bfbbcdc6c9f7f9f561e66a5f3539992e3009973f2b1314/jupytext-1.19.2-py3-none-any.whl - name: jupytext - version: 1.19.2 - sha256: 8a31e896c7e9215841783aade24336e945543057e1c2d7f00b22f9e870348688 - requires_dist: - - markdown-it-py>=1.0 - - mdit-py-plugins - - nbformat - - packaging - - pyyaml - - tomli ; python_full_version < '3.11' - - autopep8 ; extra == 'dev' - - black ; extra == 'dev' - - flake8 ; extra == 'dev' - - gitpython ; extra == 'dev' - - ipykernel ; extra == 'dev' - - isort ; extra == 'dev' - - jupyter-fs[fs]>=1.0 ; extra == 'dev' - - jupyter-server!=2.11 ; extra == 'dev' - - marimo>=0.17.6,<=0.19.4 ; extra == 'dev' - - nbconvert ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-cov>=2.6.1 ; extra == 'dev' - - pytest-randomly ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - sphinx ; extra == 'dev' - - sphinx-gallery>=0.8 ; extra == 'dev' - - myst-parser ; extra == 'docs' - - sphinx ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-rtd-theme ; extra == 'docs' - - pytest ; extra == 'test' - - pytest-asyncio ; extra == 'test' - - pytest-randomly ; extra == 'test' - - pytest-xdist ; extra == 'test' - - black ; extra == 'test-cov' - - ipykernel ; extra == 'test-cov' - - jupyter-server!=2.11 ; extra == 'test-cov' - - nbconvert ; extra == 'test-cov' - - pytest ; extra == 'test-cov' - - pytest-asyncio ; extra == 'test-cov' - - pytest-cov>=2.6.1 ; extra == 'test-cov' - - pytest-randomly ; extra == 'test-cov' - - pytest-xdist ; extra == 'test-cov' - - autopep8 ; extra == 'test-external' - - black ; extra == 'test-external' - - flake8 ; extra == 'test-external' - - gitpython ; extra == 'test-external' - - ipykernel ; extra == 'test-external' - - isort ; extra == 'test-external' - - jupyter-fs[fs]>=1.0 ; extra == 'test-external' - - jupyter-server!=2.11 ; extra == 'test-external' - - marimo>=0.17.6,<=0.19.4 ; extra == 'test-external' - - nbconvert ; extra == 'test-external' - - pre-commit ; extra == 'test-external' - - pytest ; extra == 'test-external' - - pytest-asyncio ; extra == 'test-external' - - pytest-randomly ; extra == 'test-external' - - pytest-xdist ; extra == 'test-external' - - sphinx ; extra == 'test-external' - - sphinx-gallery>=0.8 ; extra == 'test-external' - - black ; extra == 'test-functional' - - pytest ; extra == 'test-functional' - - pytest-asyncio ; extra == 'test-functional' - - pytest-randomly ; extra == 'test-functional' - - pytest-xdist ; extra == 'test-functional' - - black ; extra == 'test-integration' - - ipykernel ; extra == 'test-integration' - - jupyter-server!=2.11 ; extra == 'test-integration' - - nbconvert ; extra == 'test-integration' - - pytest ; extra == 'test-integration' - - pytest-asyncio ; extra == 'test-integration' - - pytest-randomly ; extra == 'test-integration' - - pytest-xdist ; extra == 'test-integration' - - bash-kernel ; extra == 'test-ui' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl - name: greenlet - version: 3.5.0 - sha256: d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 - requires_dist: - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - - objgraph ; extra == 'test' - - psutil ; extra == 'test' - - setuptools ; extra == 'test' - 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 @@ -10105,6 +9868,11 @@ packages: - pytest-cov ; extra == 'test' - pytz ; extra == 'test' requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.15.14 + sha256: 48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: aiohttp version: 3.13.5 @@ -10302,6 +10070,16 @@ packages: requires_dist: - typing-extensions>=4.14.1 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl + name: aiohappyeyeballs + version: 2.6.2 + sha256: 4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl + name: numpy + version: 2.4.6 + sha256: d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl name: diffpy-utils version: 3.7.2 @@ -10482,15 +10260,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: yarl - version: 1.23.0 - sha256: a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl name: pandas version: 3.0.3 @@ -10701,6 +10470,20 @@ packages: - pyyaml - pytest ; extra == 'test' - pytest-cov ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl + name: python-socketio + version: 5.16.2 + sha256: bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20 + 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/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl name: mike version: 2.2.0 @@ -10722,6 +10505,17 @@ packages: - flake8-quotes ; extra == 'test' - flake8>=3.0 ; extra == 'test' - shtab ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: greenlet + version: 3.5.1 + sha256: 2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c + requires_dist: + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' + - objgraph ; extra == 'test' + - psutil ; extra == 'test' + - setuptools ; extra == 'test' + 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 @@ -10775,6 +10569,15 @@ packages: - pywin32 ; platform_python_implementation != 'PyPy' and sys_platform == 'win32' - paramiko ; extra == 'ssh' requires_python: '>=3.9' +- 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 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-py3-none-any.whl name: bumps version: 1.0.4 @@ -10803,10 +10606,6 @@ packages: - sphinx ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl - name: trove-classifiers - version: 2026.5.7.17 - sha256: 5ec0800de5e2ddbd7c663cb4c0c15328f132dc168813897c18866c5c7b93db33 - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl name: contourpy version: 1.3.3 @@ -11001,6 +10800,17 @@ packages: - changelist==0.5 ; extra == 'dev' - spin==0.15 ; extra == 'dev' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl + name: greenlet + version: 3.5.1 + sha256: 8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 + requires_dist: + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' + - objgraph ; extra == 'test' + - psutil ; extra == 'test' + - setuptools ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl name: msgpack version: 1.1.2 @@ -11016,6 +10826,12 @@ packages: - pandas ; extra == 'test' - xarray ; extra == 'test' - pytest ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.py3-none-any.whl + name: py3dmol + version: 2.5.5 + sha256: 9717ea9a899ec641b458f53de538e5bae758281f5053be78bc2db0706ae60bcb + requires_dist: + - ipython ; extra == 'ipython' - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl name: uncertainties version: 3.2.3 @@ -11137,17 +10953,6 @@ packages: - numpy>=1.22 ; extra == 'express' - kaleido>=1.1.0 ; extra == 'kaleido' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl - name: greenlet - version: 3.5.0 - sha256: 3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 - requires_dist: - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - - objgraph ; extra == 'test' - - psutil ; extra == 'test' - - setuptools ; extra == 'test' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl name: graphviz version: '0.21' @@ -11168,6 +10973,69 @@ packages: - sphinx-autodoc-typehints ; extra == 'docs' - sphinx-rtd-theme>=0.2.5 ; extra == 'docs' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/91/6d/c00cb0d69d2e240c233c65b7f76d10522731156b28a2135bb97a05abc32c/easydiffraction-0.17.0-py3-none-any.whl + name: easydiffraction + version: 0.17.0 + sha256: fc0eb0268e71786334643ef5f222f5bf4dc0a414f93e17e444d0c40cb35f64d7 + requires_dist: + - arviz + - asciichartpy + - asteval + - bumps + - crysfml + - cryspy + - darkdetect + - dfo-ls + - diffpy-pdffit2 + - diffpy-utils + - gemmi + - h5py + - lmfit + - numpy + - pandas + - plotly + - pooch + - py3dmol + - rich + - scipy + - sympy + - typeguard + - typer + - uncertainties + - varname + - build ; extra == 'dev' + - copier ; extra == 'dev' + - docstripy ; extra == 'dev' + - essdiffraction ; 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/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl name: radon version: 6.0.1 @@ -11225,6 +11093,11 @@ packages: requires_dist: - wcwidth ; extra == 'widechars' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: numpy + version: 2.4.6 + sha256: 90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 + requires_python: '>=3.11' - 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 @@ -11397,17 +11270,6 @@ packages: name: ply version: '3.11' sha256: 096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce -- pypi: https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - name: greenlet - version: 3.5.0 - sha256: 8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 - requires_dist: - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - - objgraph ; extra == 'test' - - psutil ; extra == 'test' - - setuptools ; extra == 'test' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a4/39/3a0ae5b0edb66e61bb0e8bc53a503495cba5892297ae21faf6ba0525e681/varname-1.0.0-py3-none-any.whl name: varname version: 1.0.0 @@ -11450,6 +11312,15 @@ 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 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl name: frozenlist version: 1.8.0 @@ -11463,15 +11334,6 @@ packages: - mkdocs - pyyaml requires_python: '>=3.6' -- pypi: https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl - name: yarl - version: 1.23.0 - sha256: 63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl name: multidict version: 6.7.1 @@ -11479,24 +11341,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/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl - name: python-engineio - version: 4.13.1 - sha256: f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399 - 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/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl - name: numpy - version: 2.4.5 - sha256: 4bd2cd4ef9c0afa87de73723c0a33c0edff62143e1432917458e26d3d195d87f - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl name: aiohttp version: 3.13.5 @@ -11515,6 +11359,84 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl + name: jupytext + version: 1.19.3 + sha256: acf75492f80895ad8e664fd8db1708b617008dd0e71c341a1abc3d0d07310ed0 + requires_dist: + - markdown-it-py>=1.0 + - mdit-py-plugins + - nbformat + - packaging + - pyyaml + - tomli ; python_full_version < '3.11' + - autopep8 ; extra == 'dev' + - black ; extra == 'dev' + - flake8 ; extra == 'dev' + - gitpython ; extra == 'dev' + - ipykernel ; extra == 'dev' + - isort ; extra == 'dev' + - jupyter-fs[fs]>=1.0 ; extra == 'dev' + - jupyter-server!=2.11 ; extra == 'dev' + - marimo>=0.17.6,<=0.19.4 ; extra == 'dev' + - nbconvert ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-asyncio ; extra == 'dev' + - pytest-cov>=2.6.1 ; extra == 'dev' + - pytest-randomly ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - sphinx ; extra == 'dev' + - sphinx-gallery>=0.8 ; extra == 'dev' + - myst-parser ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-rtd-theme ; extra == 'docs' + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-randomly ; extra == 'test' + - pytest-xdist ; extra == 'test' + - black ; extra == 'test-cov' + - ipykernel ; extra == 'test-cov' + - jupyter-server!=2.11 ; extra == 'test-cov' + - nbconvert ; extra == 'test-cov' + - pytest ; extra == 'test-cov' + - pytest-asyncio ; extra == 'test-cov' + - pytest-cov>=2.6.1 ; extra == 'test-cov' + - pytest-randomly ; extra == 'test-cov' + - pytest-xdist ; extra == 'test-cov' + - autopep8 ; extra == 'test-external' + - black ; extra == 'test-external' + - flake8 ; extra == 'test-external' + - gitpython ; extra == 'test-external' + - ipykernel ; extra == 'test-external' + - isort ; extra == 'test-external' + - jupyter-fs[fs]>=1.0 ; extra == 'test-external' + - jupyter-server!=2.11 ; extra == 'test-external' + - marimo>=0.17.6,<=0.19.4 ; extra == 'test-external' + - nbconvert ; extra == 'test-external' + - pre-commit ; extra == 'test-external' + - pytest ; extra == 'test-external' + - pytest-asyncio ; extra == 'test-external' + - pytest-randomly ; extra == 'test-external' + - pytest-xdist ; extra == 'test-external' + - sphinx ; extra == 'test-external' + - sphinx-gallery>=0.8 ; extra == 'test-external' + - black ; extra == 'test-functional' + - pytest ; extra == 'test-functional' + - pytest-asyncio ; extra == 'test-functional' + - pytest-randomly ; extra == 'test-functional' + - pytest-xdist ; extra == 'test-functional' + - black ; extra == 'test-integration' + - ipykernel ; extra == 'test-integration' + - jupyter-server!=2.11 ; extra == 'test-integration' + - nbconvert ; extra == 'test-integration' + - pytest ; extra == 'test-integration' + - pytest-asyncio ; extra == 'test-integration' + - pytest-randomly ; extra == 'test-integration' + - pytest-xdist ; extra == 'test-integration' + - bash-kernel ; extra == 'test-ui' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl name: matplotlib version: 3.10.9 @@ -11544,16 +11466,16 @@ packages: - pytest ; extra == 'testing' - tox ; extra == 'testing' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl - name: ruff - version: 0.15.13 - sha256: 7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl name: jupyterlab-widgets version: 3.0.16 sha256: 45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl + name: numpy + version: 2.4.6 + sha256: d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl name: pydantic-core version: 2.46.4 @@ -11566,13 +11488,11 @@ packages: version: 1.5.0 sha256: f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl - name: click - version: 8.3.3 - sha256: a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613 - requires_dist: - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl + name: numpy + version: 2.4.6 + sha256: 3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/ae/61/3c1ea8c10bf4f6bf83c33a7f5b4a3143f4cc1f979859dec5498b6cc31900/pycifrw-5.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: pycifrw version: 5.0.1 @@ -11693,6 +11613,19 @@ packages: - setuptools-scm>=7,<10 ; extra == 'dev' - setuptools>=64 ; extra == 'dev' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl + name: python-engineio + version: 4.13.2 + sha256: 8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b + 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/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl name: python-discovery version: 1.3.1 @@ -11877,6 +11810,15 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' +- 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 + requires_python: '>=3.10' - 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 @@ -11963,70 +11905,6 @@ packages: - pydoctor>=25.4.0 ; extra == 'docs' - pytest ; extra == 'test' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/bf/e1/9e8e09ab8fc5c77f228e3271188dc4f569d12692548c42bff81b1fbba5e1/easydiffraction-0.16.0-py3-none-any.whl - name: easydiffraction - version: 0.16.0 - sha256: 2691a1e175974ca79e0ec3c219d92b77f277c38fb3b0b8d25f6f7e99696bf70f - requires_dist: - - asciichartpy - - asteval - - bumps - - colorama - - crysfml - - cryspy - - darkdetect - - dfo-ls - - diffpy-pdffit2 - - diffpy-utils - - essdiffraction - - gemmi - - lmfit - - numpy - - pandas - - plotly - - pooch - - py3dmol - - rich - - scipy - - sympy - - tabulate - - typeguard - - typer - - uncertainties - - varname - - 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-benchmark ; 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/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl name: smmap version: 5.0.3 @@ -12037,6 +11915,15 @@ packages: 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 @@ -12121,6 +12008,13 @@ packages: requires_dist: - mkdocs requires_python: '>=3.5' +- pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl + name: click + version: 8.4.1 + sha256: 482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 + requires_dist: + - colorama ; sys_platform == 'win32' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-win_amd64.whl name: ncrystal-core version: 4.4.2 @@ -12145,6 +12039,10 @@ packages: version: 4.4.2 sha256: b7e6101a6850aa18cf441825214381614db444ffcba648de8266fe1c4d1024ce requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl + name: trove-classifiers + version: 2026.5.22.10 + sha256: 01fe864225726e03efb843827ecabfe319fc4dee8dd66d65b8996cb09be46e2c - 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 @@ -12390,11 +12288,11 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/d1/0b/b905ae82d9419dc38123523862db64978ca2954b69609c3ae8fdaca1084c/numpy-2.4.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: numpy - version: 2.4.5 - sha256: 685681e956fc8dcb75adc6ff26694e1dfd738b24bd8d4696c51ca0110157f912 - requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl + name: ruff + version: 0.15.14 + sha256: 802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902 + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl name: colorama version: 0.4.6 @@ -12409,11 +12307,6 @@ packages: - argparse ; python_full_version < '2.7' - funcsigs ; python_full_version < '3.3' - rst2ansi ; extra == 'restructuredtext' -- pypi: https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl - name: ruff - version: 0.15.13 - sha256: 1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl name: pytest version: 9.0.3 @@ -12764,6 +12657,11 @@ packages: - mkdocs-section-index ; extra == 'docs' - mkdocs-literate-nav ; extra == 'docs' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl + name: numpy + version: 2.4.6 + sha256: b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl name: multidict version: 6.7.1 @@ -12835,11 +12733,6 @@ packages: version: 1.5.0 sha256: 80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: ruff - version: 0.15.13 - sha256: cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: gemmi version: 0.7.5 @@ -12935,6 +12828,11 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: numpy + version: 2.4.6 + sha256: a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl name: virtualenv version: 21.3.3 @@ -12965,15 +12863,6 @@ packages: - pytest>=8.0 ; extra == 'test' - scippneutron>=24.12.0 ; extra == 'test' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl - name: yarl - version: 1.23.0 - sha256: f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - 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 @@ -13036,6 +12925,15 @@ packages: - 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 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl name: toolz version: 1.1.0 diff --git a/src/easydiffraction/__main__.py b/src/easydiffraction/__main__.py index a66d0d8f8..7f87520e0 100644 --- a/src/easydiffraction/__main__.py +++ b/src/easydiffraction/__main__.py @@ -56,7 +56,8 @@ def _display_project_patterns(project: object) -> None: def _project_fit_mode(project: object) -> str | None: """Return the resolved fitting mode type for one project.""" - return getattr(project.analysis, 'fitting_mode_type', None) + fitting_mode = getattr(project.analysis, 'fitting_mode', None) + return getattr(fitting_mode, 'type', None) def _project_result_kind(project: object) -> str | None: @@ -84,7 +85,7 @@ def _display_project_outputs(project: object) -> None: project.display.fit.correlations() if _project_result_kind(project) == 'bayesian': - if project.rendering.plotter.engine == 'plotly': + if project.chart.plotter.engine == 'plotly': project.display.posterior.pairs() project.display.posterior.distribution() for experiment in project.experiments: diff --git a/src/easydiffraction/analysis/__init__.py b/src/easydiffraction/analysis/__init__.py index f9d5e94de..186c375c8 100644 --- a/src/easydiffraction/analysis/__init__.py +++ b/src/easydiffraction/analysis/__init__.py @@ -1,44 +1,6 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from easydiffraction.analysis.categories.bayesian_convergence import BayesianConvergence -from easydiffraction.analysis.categories.bayesian_convergence import BayesianConvergenceFactory -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCacheItem, -) -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCaches, -) -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCachesFactory, -) -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCacheItem -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCaches -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCachesFactory -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriorItem, -) -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriors, -) -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriorsFactory, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasetItem, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasets, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasetsFactory, -) -from easydiffraction.analysis.categories.bayesian_result import BayesianResult -from easydiffraction.analysis.categories.bayesian_result import BayesianResultFactory -from easydiffraction.analysis.categories.bayesian_sampler import BayesianSampler -from easydiffraction.analysis.categories.bayesian_sampler import BayesianSamplerFactory -from easydiffraction.analysis.categories.deterministic_result import DeterministicResult -from easydiffraction.analysis.categories.deterministic_result import DeterministicResultFactory from easydiffraction.analysis.categories.fit_parameter_correlations import ( FitParameterCorrelationItem, ) @@ -51,11 +13,21 @@ from easydiffraction.analysis.categories.fit_parameters import FitParametersFactory from easydiffraction.analysis.categories.fit_result import FitResult from easydiffraction.analysis.categories.fit_result import FitResultFactory -from easydiffraction.analysis.categories.fitting import Fitting -from easydiffraction.analysis.categories.fitting import FittingFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection from easydiffraction.analysis.categories.joint_fit import JointFitFactory from easydiffraction.analysis.categories.joint_fit import JointFitItem +from easydiffraction.analysis.categories.minimizer import BayesianMinimizerBase +from easydiffraction.analysis.categories.minimizer import BumpsAmoebaMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsDeMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsDreamMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsLmMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsMinimizer +from easydiffraction.analysis.categories.minimizer import DfolsMinimizer +from easydiffraction.analysis.categories.minimizer import LeastSquaresMinimizerBase +from easydiffraction.analysis.categories.minimizer import LmfitLeastsqMinimizer +from easydiffraction.analysis.categories.minimizer import LmfitLeastSquaresMinimizer +from easydiffraction.analysis.categories.minimizer import LmfitMinimizer +from easydiffraction.analysis.categories.minimizer import MinimizerCategoryFactory from easydiffraction.analysis.categories.sequential_fit import SequentialFit from easydiffraction.analysis.categories.sequential_fit import SequentialFitFactory from easydiffraction.analysis.categories.sequential_fit_extract import ( diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index eccbf28d8..87676646e 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -6,36 +6,22 @@ from contextlib import suppress from itertools import combinations from pathlib import Path +from typing import TYPE_CHECKING import numpy as np import pandas as pd from easydiffraction.analysis.categories.aliases.factory import AliasesFactory -from easydiffraction.analysis.categories.bayesian_convergence import BayesianConvergence -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCaches, -) -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCaches -from easydiffraction.analysis.categories.bayesian_pair_caches.default import BayesianPairCachePaths -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriors, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasets, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasetPaths, -) -from easydiffraction.analysis.categories.bayesian_result import BayesianResult -from easydiffraction.analysis.categories.bayesian_sampler import BayesianSampler from easydiffraction.analysis.categories.constraints.factory import ConstraintsFactory -from easydiffraction.analysis.categories.deterministic_result import DeterministicResult from easydiffraction.analysis.categories.fit_parameter_correlations import FitParameterCorrelations from easydiffraction.analysis.categories.fit_parameters import FitParameters from easydiffraction.analysis.categories.fit_result import FitResult -from easydiffraction.analysis.categories.fitting import Fitting -from easydiffraction.analysis.categories.fitting import FittingFactory +from easydiffraction.analysis.categories.fitting_mode import FittingMode +from easydiffraction.analysis.categories.fitting_mode import FittingModeFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection +from easydiffraction.analysis.categories.minimizer import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase +from easydiffraction.analysis.categories.minimizer.bayesian_base import BayesianMinimizerBase from easydiffraction.analysis.categories.sequential_fit import SequentialFit from easydiffraction.analysis.categories.sequential_fit import SequentialFitFactory from easydiffraction.analysis.categories.sequential_fit_extract import ( @@ -45,7 +31,6 @@ from easydiffraction.analysis.enums import FitModeEnum from easydiffraction.analysis.enums import FitResultKindEnum from easydiffraction.analysis.fit_helpers.bayesian import BayesianFitResults -from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary from easydiffraction.analysis.fit_helpers.bayesian import PosteriorPredictiveSummary from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples from easydiffraction.analysis.fit_helpers.reporting import FitResults @@ -70,12 +55,25 @@ from easydiffraction.utils.utils import render_object_help from easydiffraction.utils.utils import render_table +if TYPE_CHECKING: + from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase + from easydiffraction.core.posterior import PosteriorParameterSummary + _SUMMARY_HIDDEN_PARAMETER_CATEGORIES = frozenset({'pd_data', 'total_data', 'refln'}) _POSTERIOR_SAMPLE_NDIM = 3 _FLATTENED_POSTERIOR_SAMPLE_NDIM = 2 _CREDIBLE_INTERVAL_LEVEL_COUNT = 2 +# LSQ result descriptors default to ``None`` (review-8 F6); the CIF +# restore path calls ``int(...)`` on every result field, which would +# crash for a CIF saved before any fit ran. ``_int_or_none`` lets the +# call site stay terse while preserving ``None`` through the coercion. +def _int_or_none(value: object) -> int | None: + """Coerce a descriptor value to ``int``; ``None`` passes through.""" + return None if value is None else int(value) + + def _discover_property_rows(cls: type) -> list[list[str]]: """Return public property rows for analysis help tables.""" return _help_property_rows(cls) @@ -440,46 +438,6 @@ def fit_parameter_correlations(self) -> FitParameterCorrelations: """Persisted fit-parameter correlation summaries.""" return self._fit_parameter_correlations - @property - def deterministic_result(self) -> DeterministicResult: - """Persisted deterministic fit-result metadata.""" - return self._deterministic_result - - @property - def bayesian_result(self) -> BayesianResult: - """Persisted Bayesian fit-result metadata.""" - return self._bayesian_result - - @property - def bayesian_sampler(self) -> BayesianSampler: - """Persisted Bayesian sampler settings.""" - return self._bayesian_sampler - - @property - def bayesian_convergence(self) -> BayesianConvergence: - """Persisted Bayesian convergence diagnostics.""" - return self._bayesian_convergence - - @property - def bayesian_parameter_posteriors(self) -> BayesianParameterPosteriors: - """Persisted Bayesian parameter posterior summaries.""" - return self._bayesian_parameter_posteriors - - @property - def bayesian_distribution_caches(self) -> BayesianDistributionCaches: - """Persisted Bayesian distribution-cache manifests.""" - return self._bayesian_distribution_caches - - @property - def bayesian_pair_caches(self) -> BayesianPairCaches: - """Persisted Bayesian pair-cache manifests.""" - return self._bayesian_pair_caches - - @property - def bayesian_predictive_datasets(self) -> BayesianPredictiveDatasets: - """Persisted Bayesian predictive-dataset manifests.""" - return self._bayesian_predictive_datasets - class Analysis( _AnalysisOwnerAccessorsMixin, @@ -510,8 +468,12 @@ def __init__(self, project: object) -> None: self._constraints_type: str = ConstraintsFactory.default_tag() self._constraints = ConstraintsFactory.create(self._constraints_type) self._constraints_handler = ConstraintsHandler.get() - self._fitting: Fitting = FittingFactory.create(FittingFactory.default_tag()) - self._fitting_mode_type: FitModeEnum = FitModeEnum.default() + self._minimizer: MinimizerCategoryBase = MinimizerCategoryFactory.create( + MinimizerTypeEnum.default().value + ) + self._fitting_mode: FittingMode = FittingModeFactory.create( + FittingModeFactory.default_tag() + ) self._joint_fit: JointFitCollection = JointFitCollection() self._sequential_fit: SequentialFit = SequentialFitFactory.create( SequentialFitFactory.default_tag() @@ -520,20 +482,47 @@ def __init__(self, project: object) -> None: self._fit_parameters = FitParameters() self._fit_result = FitResult() self._fit_parameter_correlations = FitParameterCorrelations() - self._deterministic_result = DeterministicResult() - self._bayesian_result = BayesianResult() - self._bayesian_sampler = BayesianSampler() - self._bayesian_convergence = BayesianConvergence() - self._bayesian_parameter_posteriors = BayesianParameterPosteriors() - self._bayesian_distribution_caches = BayesianDistributionCaches() - self._bayesian_pair_caches = BayesianPairCaches() - self._bayesian_predictive_datasets = BayesianPredictiveDatasets() self._has_persisted_fit_state_data = False self._persisted_fit_state_sidecar: dict[str, object] = {} - self._fitter = Fitter(self._fitting.minimizer_type.value) + self._fitter = Fitter(self.minimizer.type) self._fit_results = None self._parameter_snapshots: dict[str, dict[str, dict]] = {} self._display = AnalysisDisplay(self) + self._attach_category_parents() + + def _attach_category_parents(self) -> None: + """Link owned categories back to this analysis object.""" + self._aliases._parent = self + self._constraints._parent = self + self._minimizer._parent = self + self._fitting_mode._parent = self + self._joint_fit._parent = self + self._sequential_fit._parent = self + self._sequential_fit_extract._parent = self + self._fit_parameters._parent = self + self._fit_result._parent = self + self._fit_parameter_correlations._parent = self + + @staticmethod + def _supported_filters_for(category: object) -> dict[str, object]: + """ + Return owner context filters for a switchable category. + + Analysis-level switchables (minimizer, fitting_mode) have no + owner-supplied context today; their supported-types lookups read + only the registered factory entries. The empty dict is therefore + intentional and applies uniformly across both categories. + """ + del category + return {} + + def _swap_minimizer(self, new_type: str) -> None: + """Switch the active minimizer category.""" + self._replace_minimizer(new_type, announce=True) + + def _swap_fitting_mode(self, new_type: str) -> None: + """Switch the active fitting-mode category.""" + self._replace_fitting_mode(new_type, announce=True) @staticmethod def _predictive_cache_key( @@ -559,11 +548,6 @@ def _ordered_restored_parameter_names(self) -> list[str]: """ Return persisted parameter names in display and array order. """ - if self.fit_result.result_kind.value == FitResultKindEnum.BAYESIAN.value: - posterior_rows = list(self.bayesian_parameter_posteriors) - if posterior_rows: - return [row.unique_name.value for row in posterior_rows] - return [row.param_unique_name.value for row in self.fit_parameters] def _restore_live_parameter_state(self, param_map: dict[str, Parameter]) -> None: @@ -584,48 +568,10 @@ def _restore_live_parameter_state(self, param_map: dict[str, Parameter]) -> None ) parameter._fit_start_value = row.start_value.value parameter._fit_start_uncertainty = row.start_uncertainty.value - - for row in self.bayesian_parameter_posteriors: - parameter = param_map.get(row.unique_name.value) - if parameter is None or row.uncertainty.value is None: - continue - parameter.uncertainty = float(row.uncertainty.value) - - def _sync_live_minimizer_from_persisted_fit_state(self) -> None: - """Apply saved sampler settings to the live minimizer.""" - if not self._has_persisted_fit_state(): - return - - if self.fit_result.result_kind.value != FitResultKindEnum.BAYESIAN.value: - return - - if self.fitting.minimizer_type.value != MinimizerTypeEnum.BUMPS_DREAM.value: - return - - minimizer = self.fitting.minimizer - if minimizer is None: - return - - steps = int(self.bayesian_sampler.steps.value) - if steps <= 0: - return - - minimizer.steps = steps - minimizer.burn = int(self.bayesian_sampler.burn.value) - - thin = int(self.bayesian_sampler.thin.value) - if thin > 0: - minimizer.thin = thin - - pop = int(self.bayesian_sampler.pop.value) - if pop > 0: - minimizer.pop = pop - - minimizer.parallel = int(self.bayesian_sampler.parallel.value) - - init_value = str(self.bayesian_sampler.init.value) - if init_value: - minimizer.init = init_value + posterior = row.posterior_summary(display_name=parameter.name) + parameter._set_posterior(posterior) + if posterior is not None and np.isfinite(posterior.standard_deviation): + parameter.uncertainty = posterior.standard_deviation def _restored_fit_parameters(self, param_map: dict[str, Parameter]) -> list[Parameter]: """Return live parameters in the persisted fit-result order.""" @@ -638,18 +584,13 @@ def _restored_fit_parameters(self, param_map: dict[str, Parameter]) -> list[Para def _restored_posterior_samples(self) -> PosteriorSamples | None: """Return restored posterior samples from the HDF5 sidecar.""" - if not self.bayesian_result.has_posterior_samples.value: - return None - posterior_data = self._persisted_fit_state_sidecar.get('posterior', {}) parameter_samples = posterior_data.get('parameter_samples') if parameter_samples is None: return None - posterior_rows = list(self.bayesian_parameter_posteriors) - parameter_names = [row.unique_name.value for row in posterior_rows] - if not parameter_names: - parameter_names = [row.param_unique_name.value for row in self.fit_parameters] + posterior_rows = [row for row in self.fit_parameters if row.has_posterior_summary()] + parameter_names = [row.param_unique_name.value for row in posterior_rows] parameter_sample_array = np.asarray(parameter_samples, dtype=float) if parameter_sample_array.ndim != _POSTERIOR_SAMPLE_NDIM: @@ -674,38 +615,23 @@ def _restored_posterior_samples(self) -> PosteriorSamples | None: def _restored_posterior_summaries(self) -> list[PosteriorParameterSummary]: """Return posterior summary rows as runtime summary objects.""" - return [ - PosteriorParameterSummary( - unique_name=row.unique_name.value, - display_name=row.display_name.value, - best_sample_value=float(row.best_sample_value.value), - median=float(row.median.value), - standard_deviation=float(row.uncertainty.value), - interval_68=( - float(row.interval_68_lower.value), - float(row.interval_68_upper.value), - ), - interval_95=( - float(row.interval_95_lower.value), - float(row.interval_95_upper.value), - ), - ess_bulk=row.ess_bulk.value, - r_hat=row.r_hat.value, - ) - for row in self.bayesian_parameter_posteriors - ] + param_map = self._live_parameter_map() + summaries: list[PosteriorParameterSummary] = [] + for row in self.fit_parameters: + parameter = param_map.get(row.param_unique_name.value) + display_name = row.param_unique_name.value if parameter is None else parameter.name + summary = row.posterior_summary(display_name=display_name) + if summary is not None: + summaries.append(summary) + return summaries def _restored_predictive_summaries(self) -> dict[str, PosteriorPredictiveSummary]: """Return restored predictive summaries for runtime reuse.""" restored_predictive: dict[str, PosteriorPredictiveSummary] = {} predictive_data = self._persisted_fit_state_sidecar.get('predictive_datasets', {}) - for row in self.bayesian_predictive_datasets: - experiment_name = str(row.experiment_name.value) - x_axis_name = str(row.x_axis_name.value) - dataset = predictive_data.get(experiment_name) - if dataset is None: - continue - + for item_id, dataset in predictive_data.items(): + experiment_name = str(item_id) + x_axis_name = str(dataset.get('x_axis_name', '')) summary = PosteriorPredictiveSummary( experiment_name=experiment_name, x_axis_name=x_axis_name, @@ -763,6 +689,27 @@ def _restore_fit_results_from_projection(self) -> object | None: if not self._has_persisted_fit_state(): return None + # Validate the (result_kind, minimizer family) pair before + # touching any live parameters or posterior arrays, so a CIF + # whose tags disagree fails fast with a clear error rather than + # crashing deep inside the Bayesian restore (review-8 F5). + if ( + self.fit_result.result_kind.value == FitResultKindEnum.BAYESIAN.value + and not isinstance(self.minimizer, BayesianMinimizerBase) + ): + bayesian_kind = FitResultKindEnum.BAYESIAN.value + deterministic_kind = FitResultKindEnum.DETERMINISTIC.value + msg = ( + 'CIF restore mismatch: ' + f"_fit_result.result_kind = '{bayesian_kind}' " + f"but _minimizer.type = '{self.minimizer.type}' " + 'is not a Bayesian minimizer. Either set ' + '_minimizer.type to a Bayesian sampler ' + '(e.g. bumps (dream)), or set _fit_result.result_kind ' + f"to '{deterministic_kind}'." + ) + raise ValueError(msg) + param_map = self._live_parameter_map() self._restore_live_parameter_state(param_map) restored_parameters = self._restored_fit_parameters(param_map) @@ -770,111 +717,115 @@ def _restore_fit_results_from_projection(self) -> object | None: reduced_chi_square = self.fit_result.reduced_chi_square.value if self.fit_result.result_kind.value == FitResultKindEnum.BAYESIAN.value: + posterior_samples = self._restored_posterior_samples() + sample_shape = ( + np.asarray(posterior_samples.parameter_samples).shape + if posterior_samples is not None + else (0, 0, 0) + ) + sampler_settings = self.minimizer._native_kwargs() + sampler_name = ( + 'dream' + if self.minimizer.type == MinimizerTypeEnum.BUMPS_DREAM.value + else str(self.minimizer.type) + ) restored_results = BayesianFitResults( success=bool(self.fit_result.success.value), parameters=restored_parameters, reduced_chi_square=reduced_chi_square, starting_parameters=list(restored_parameters), fitting_time=fitting_time, - sampler_name=self.bayesian_result.sampler_name.value, - point_estimate_name=self.bayesian_result.point_estimate_name.value, - posterior_samples=self._restored_posterior_samples(), + sampler_name=sampler_name, + point_estimate_name=self.minimizer.point_estimate_name.value, + posterior_samples=posterior_samples, posterior_parameter_summaries=self._restored_posterior_summaries(), posterior_predictive=self._restored_predictive_summaries(), credible_interval_levels=( - float(self.bayesian_result.credible_interval_inner.value), - float(self.bayesian_result.credible_interval_outer.value), + float(self.minimizer.credible_interval_inner.value), + float(self.minimizer.credible_interval_outer.value), ), sampler_settings={ - 'steps': int(self.bayesian_sampler.steps.value), - 'burn': int(self.bayesian_sampler.burn.value), - 'thin': int(self.bayesian_sampler.thin.value), - 'pop': int(self.bayesian_sampler.pop.value), - 'parallel': int(self.bayesian_sampler.parallel.value), - 'init': self.bayesian_sampler.init.value, - 'random_seed': self.bayesian_sampler.random_seed.value, + 'steps': int(sampler_settings.get('steps', 0)), + 'burn': int(sampler_settings.get('burn', 0)), + 'thin': int(sampler_settings.get('thin', 0)), + 'pop': int(sampler_settings.get('pop', 0)), + 'parallel': int(sampler_settings.get('parallel', 0)), + 'init': str(sampler_settings.get('init', '')), + 'random_seed': sampler_settings.get('random_seed'), }, convergence_diagnostics={ - 'converged': bool(self.bayesian_convergence.converged.value), - 'max_r_hat': self.bayesian_convergence.max_r_hat.value, - 'min_ess_bulk': self.bayesian_convergence.min_ess_bulk.value, - 'n_draws': int(self.bayesian_convergence.n_draws.value), - 'n_chains': int(self.bayesian_convergence.n_chains.value), - 'n_parameters': int(self.bayesian_convergence.n_parameters.value), + 'converged': False, + 'max_r_hat': self.minimizer.gelman_rubin_max.value, + 'min_ess_bulk': self.minimizer.effective_sample_size_min.value, + 'n_draws': int(sample_shape[0]), + 'n_chains': int(sample_shape[1]), + 'n_parameters': int(sample_shape[2]), }, - sampler_completed=bool(self.bayesian_result.sampler_completed.value), - best_log_posterior=self.bayesian_result.best_log_posterior.value, + sampler_completed=bool(self.minimizer.sampler_completed.value), + best_log_posterior=self.minimizer.best_log_posterior.value, ) restored_results.message = self.fit_result.message.value restored_results.iterations = int(self.fit_result.iterations.value) self.fit_results = restored_results return restored_results + engine_metadata = type(self.minimizer)._engine_metadata restored_results = FitResults( success=bool(self.fit_result.success.value), parameters=restored_parameters, reduced_chi_square=reduced_chi_square, starting_parameters=list(restored_parameters), fitting_time=fitting_time, - optimizer_name=self.deterministic_result.optimizer_name.value, - method_name=self.deterministic_result.method_name.value, - objective_name=self.deterministic_result.objective_name.value, - objective_value=self.deterministic_result.objective_value.value, - n_data_points=int(self.deterministic_result.n_data_points.value), - n_parameters=int(self.deterministic_result.n_parameters.value), - n_free_parameters=int(self.deterministic_result.n_free_parameters.value), - degrees_of_freedom=int(self.deterministic_result.degrees_of_freedom.value), - covariance_available=bool(self.deterministic_result.covariance_available.value), - correlation_available=bool(self.deterministic_result.correlation_available.value), + optimizer_name=engine_metadata['optimizer_name'], + method_name=engine_metadata['method_name'], + objective_name=self.minimizer.objective_name.value, + objective_value=self.minimizer.objective_value.value, + n_data_points=_int_or_none(self.minimizer.n_data_points.value), + n_parameters=_int_or_none(self.minimizer.n_parameters.value), + n_free_parameters=_int_or_none(self.minimizer.n_free_parameters.value), + degrees_of_freedom=_int_or_none(self.minimizer.degrees_of_freedom.value), + covariance_available=self.minimizer.covariance_available.value, + correlation_available=self.minimizer.correlation_available.value, + runtime_seconds=self.minimizer.runtime_seconds.value, + iterations_performed=_int_or_none(self.minimizer.iterations_performed.value), + exit_reason=self.minimizer.exit_reason.value, ) restored_results.message = self.fit_result.message.value restored_results.iterations = int(self.fit_result.iterations.value) - restored_results.chi_square = self.deterministic_result.objective_value.value + restored_results.chi_square = self.minimizer.objective_value.value self.fit_results = restored_results return restored_results def help(self) -> None: """Print a summary of analysis properties and methods.""" cls = type(self) - console.paragraph(f"Help for '{cls.__name__}'") property_rows = _discover_property_rows(cls) method_rows = _discover_method_rows(cls) - property_names = [row[1] for row in property_rows] - method_names = [row[1][:-2] for row in method_rows] + property_names = [row[0] for row in property_rows] + method_names = [row[0][:-2] for row in method_rows] property_names, method_names = _apply_help_filter(self, property_names, method_names) filtered_property_names = set(property_names) filtered_method_names = set(method_names) - filtered_property_rows = [] - for row in property_rows: - if row[1] in filtered_property_names: - filtered_property_rows.append([ - str(len(filtered_property_rows) + 1), - row[1], - row[2], - row[3], - ]) - - filtered_method_rows = [] - for row in method_rows: - method_name = row[1][:-2] - if method_name in filtered_method_names: - filtered_method_rows.append([str(len(filtered_method_rows) + 1), row[1], row[2]]) + filtered_property_rows = [ + row for row in property_rows if row[0] in filtered_property_names + ] + filtered_method_rows = [row for row in method_rows if row[0][:-2] in filtered_method_names] if filtered_property_rows: console.paragraph('Properties') render_table( - columns_headers=['#', 'Name', 'Writable', 'Description'], - columns_alignment=['right', 'left', 'center', 'left'], + columns_headers=['Name', 'Writable', 'Description'], + columns_alignment=['left', 'center', 'left'], columns_data=filtered_property_rows, ) if filtered_method_rows: console.paragraph('Methods') render_table( - columns_headers=['#', 'Name', 'Description'], - columns_alignment=['right', 'left', 'left'], + columns_headers=['Name', 'Description'], + columns_alignment=['left', 'left'], columns_data=filtered_method_rows, ) @@ -884,12 +835,13 @@ def _help_filter( methods: list[str], ) -> tuple[list[str], list[str]]: """Hide inactive mode-specific categories from analysis help.""" + mode = FitModeEnum(self._fitting_mode.type) hidden_properties: set[str] - if self._fitting_mode_type is FitModeEnum.SINGLE: + if mode is FitModeEnum.SINGLE: hidden_properties = {'joint_fit', 'sequential_fit', 'sequential_fit_extract'} - elif self._fitting_mode_type is FitModeEnum.JOINT: + elif mode is FitModeEnum.JOINT: hidden_properties = {'sequential_fit', 'sequential_fit_extract'} - elif self._fitting_mode_type is FitModeEnum.SEQUENTIAL: + elif mode is FitModeEnum.SEQUENTIAL: hidden_properties = {'joint_fit'} else: # pragma: no cover hidden_properties = set() @@ -900,14 +852,16 @@ def _help_filter( def _serializable_categories(self) -> list: """Serializable analysis categories for the active fit mode.""" categories = [ - self.fitting, + self.fitting_mode, + self.minimizer, self.aliases, self.constraints, ] - if self._fitting_mode_type is FitModeEnum.JOINT: + mode = FitModeEnum(self._fitting_mode.type) + if mode is FitModeEnum.JOINT: categories.append(self.joint_fit) - elif self._fitting_mode_type is FitModeEnum.SEQUENTIAL: + elif mode is FitModeEnum.SEQUENTIAL: categories.extend([ self.sequential_fit, self.sequential_fit_extract, @@ -974,7 +928,7 @@ def _get_params_as_dataframe( def fit(self) -> None: """Execute fitting for the currently selected fitting mode.""" - mode = self._fitting_mode_type + mode = FitModeEnum(self._fitting_mode.type) if mode is FitModeEnum.SINGLE: self._run_single() elif mode is FitModeEnum.JOINT: @@ -986,6 +940,18 @@ def fit(self) -> None: msg = f'Unknown fit mode: {mode!r}' raise ValueError(msg) + def _warn_results_sidecar_overwrite(self) -> None: + """Warn before persisted sidecar arrays are overwritten.""" + project_path = self.project.info.path + if project_path is None: + return + + from easydiffraction.io.results_sidecar import ( # noqa: PLC0415 + warn_analysis_results_sidecar_overwrite, + ) + + warn_analysis_results_sidecar_overwrite(analysis_dir=project_path / 'analysis') + def _prepare_joint_fit(self) -> None: """ Auto-populate and validate joint-fit rows before execution. @@ -1023,62 +989,166 @@ def _prepare_joint_fit(self) -> None: raise ValueError(msg) @property - def fitting(self) -> Fitting: - """Fitting configuration category.""" - return self._fitting + def fitting_mode(self) -> FittingMode: + """Active fitting-mode selector category.""" + return self._fitting_mode - @property - def fitting_mode_type(self) -> str: - """Currently selected fitting mode.""" - return self._fitting_mode_type.value - - @fitting_mode_type.setter - def fitting_mode_type(self, value: str) -> None: + def _replace_fitting_mode( + self, + value: str, + *, + announce: bool, + strict: bool = True, + ) -> None: + """Set the active fitting mode.""" supported = [mode.value for mode in FitModeEnum] try: new_mode = FitModeEnum(value) except ValueError: - log.warning( + msg = ( f"Unsupported fitting mode '{value}'. " f'Supported fitting modes: {supported}. ' - f"For more information, use 'show_fitting_mode_types()'", + f"For more information, use 'fitting_mode.show_supported()'" ) + if strict: + raise ValueError(msg) from None + log.warning(msg) return - self._fitting_mode_type = new_mode - console.paragraph('Fitting mode changed to') - console.print(self._fitting_mode_type.value) - - def show_fitting_mode_types(self) -> None: - """Print supported fitting modes and mark the current type.""" - columns_data = [ - [ - '*' if mode is self._fitting_mode_type else '', - mode.value, - mode.description(), - ] - for mode in FitModeEnum - ] - console.paragraph('Fitting mode types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) + self._fitting_mode._type.value = new_mode.value + if announce: + console.paragraph('Fitting mode changed to') + console.print(new_mode.value) def _set_fitting_mode_type(self, value: str) -> None: """Set the fitting mode without console output.""" - supported = [mode.value for mode in FitModeEnum] + self._replace_fitting_mode(value, announce=False, strict=False) - try: - self._fitting_mode_type = FitModeEnum(value) - except ValueError: + @property + def minimizer(self) -> MinimizerCategoryBase: + """Active minimizer settings and result category.""" + return self._minimizer + + def _replace_minimizer( + self, + value: str, + *, + announce: bool, + strict: bool = True, + ) -> None: + """Replace the active minimizer category.""" + supported = [str(tag) for tag in MinimizerCategoryFactory.supported_tags()] + if value not in supported: + msg = ( + f"Unsupported minimizer type '{value}'. " + f'Supported minimizer types: {supported}. ' + f"For more information, use 'minimizer.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + + if value == self.minimizer.type: + if announce: + console.paragraph('Current minimizer already set to') + console.print(value) + return + + old_minimizer = self._minimizer + new_minimizer = MinimizerCategoryFactory.create(value) + old_defaults = MinimizerCategoryFactory.create(self.minimizer.type) + self._warn_about_minimizer_swap_defaults(old_defaults, new_minimizer) + + old_minimizer._parent = None + self._minimizer = new_minimizer + self._minimizer._parent = self + self._fitter = Fitter(value) + if announce: + console.paragraph('Current minimizer changed to') + console.print(value) + + def _set_minimizer_type(self, value: str) -> None: + """Set the minimizer type without console output.""" + self._replace_minimizer(value, announce=False, strict=False) + + @staticmethod + def _minimizer_swap_diff( + old_minimizer: MinimizerCategoryBase, + new_minimizer: MinimizerCategoryBase, + ) -> tuple[list[str], list[str], list[str]]: + """ + Return (removed, added, changed) setting-name lists for a swap. + + ``removed`` lists settings present on ``old_minimizer`` but not + on ``new_minimizer`` (a value the user previously customised is + no longer applicable). ``added`` lists settings introduced by + the new minimizer with their default value. ``changed`` lists + settings shared by both whose default value differs, in the + ``'{name}={old!r}->{new!r}'`` form. + """ + old_values = old_minimizer._descriptor_values(old_minimizer._setting_descriptor_names) + new_values = new_minimizer._descriptor_values(new_minimizer._setting_descriptor_names) + old_keys = set(old_values) + new_keys = set(new_values) + removed = sorted(old_keys - new_keys) + added = sorted(f'{name}={new_values[name]!r}' for name in (new_keys - old_keys)) + changed = sorted( + f'{name}={old_values[name]!r}->{new_values[name]!r}' + for name in (old_keys & new_keys) + if old_values[name] != new_values[name] + ) + return removed, added, changed + + @classmethod + def _warn_about_minimizer_swap_defaults( + cls, + old_minimizer: MinimizerCategoryBase, + new_minimizer: MinimizerCategoryBase, + ) -> None: + """ + Emit human-readable warnings about a minimizer swap. + + Splits the diff into "removed", "added", "changed" lines so the + message stays legible for scientists when an inter-family swap + replaces the whole setting surface (e.g. ``lmfit`` → ``bumps + (dream)``). Same-family swaps still see the per-field + ``old->new`` line for actual default differences. + """ + removed, added, changed = cls._minimizer_swap_diff(old_minimizer, new_minimizer) + if removed: + log.warning(f'Switching minimizer type removes these settings: {", ".join(removed)}.') + if added: log.warning( - f"Unsupported fitting mode '{value}' in CIF. " - f'Supported: {supported}. Keeping default.', + f'Switching minimizer type adds these settings with defaults: {", ".join(added)}.' + ) + if changed: + log.warning( + f'Switching minimizer type changes these default values: {", ".join(changed)}.' ) + def _sync_engine_from_minimizer_category(self) -> None: + """Apply minimizer category settings to the live engine.""" + engine = self.fitter.minimizer + for key, value in self.minimizer._native_kwargs().items(): + if key == 'random_seed': + continue + if not hasattr(engine, key): + log.warning( + f"Minimizer setting '{key}' is not supported by " + f"engine '{self.minimizer.type}'." + ) + continue + setattr(engine, key, value) + + def _resolved_fit_random_seed(self, random_seed: int | None) -> int | None: + """Return call-time or minimizer-category random seed.""" + if random_seed is not None: + return random_seed + seed = self.minimizer._native_kwargs().get('random_seed') + return None if seed is None else int(seed) + # ------------------------------------------------------------------ # Joint-fit weights (category) # ------------------------------------------------------------------ @@ -1127,36 +1197,23 @@ def _fit_state_categories(self) -> list[object]: return categories if result_kind is FitResultKindEnum.DETERMINISTIC: - categories.append(self.deterministic_result) return categories - categories.extend([ - self.bayesian_result, - self.bayesian_sampler, - self.bayesian_convergence, - self.bayesian_parameter_posteriors, - self.bayesian_distribution_caches, - self.bayesian_pair_caches, - self.bayesian_predictive_datasets, - ]) return categories def _clear_persisted_fit_state(self) -> None: """Reset all persisted fit-state categories before a new fit.""" + self._clear_minimizer_result_projection() self._fit_parameters = FitParameters() self._fit_result = FitResult() self._fit_parameter_correlations = FitParameterCorrelations() - self._deterministic_result = DeterministicResult() - self._bayesian_result = BayesianResult() - self._bayesian_sampler = BayesianSampler() - self._bayesian_convergence = BayesianConvergence() - self._bayesian_parameter_posteriors = BayesianParameterPosteriors() - self._bayesian_distribution_caches = BayesianDistributionCaches() - self._bayesian_pair_caches = BayesianPairCaches() - self._bayesian_predictive_datasets = BayesianPredictiveDatasets() self._set_has_persisted_fit_state(value=False) self._persisted_fit_state_sidecar = {} + def _clear_minimizer_result_projection(self) -> None: + """Reset result-only fields on the active minimizer category.""" + self.minimizer._reset_result_descriptors() + def _capture_fit_parameter_state(self, parameters: list[Parameter]) -> None: """Capture pre-fit parameter state.""" self._clear_persisted_fit_state() @@ -1295,14 +1352,14 @@ def _store_correlation_projection( correlation=float(np.clip(correlation, -1.0, 1.0)), ) - def _store_deterministic_result_projection( + def _store_least_squares_result_projection( self, results: FitResults, *, experiments: list[object], fitted_parameters: list[Parameter], ) -> None: - """Store deterministic fit results in persisted categories.""" + """Store least-squares result fields.""" selected_parameters = self._selected_parameters_for_fit(experiments) n_parameters = len(selected_parameters) n_free_parameters = len(fitted_parameters) @@ -1315,18 +1372,17 @@ def _store_deterministic_result_projection( else None ) - self.deterministic_result._set_optimizer_name( - str(self.fitter.minimizer.name or self.fitter.selection) - ) - self.deterministic_result._set_method_name(str(self.fitter.minimizer.method or '')) - self.deterministic_result._set_objective_name('chi_square') - self.deterministic_result._set_objective_value(self._resolve_objective_value(results)) - self.deterministic_result._set_n_data_points(n_data_points) - self.deterministic_result._set_n_parameters(n_parameters) - self.deterministic_result._set_n_free_parameters(n_free_parameters) - self.deterministic_result._set_degrees_of_freedom(degrees_of_freedom) - self.deterministic_result._set_covariance_available(value=covariance is not None) - self.deterministic_result._set_correlation_available(value=correlation_matrix is not None) + self.minimizer._set_objective_name('chi_square') + self.minimizer._set_objective_value(self._resolve_objective_value(results)) + self.minimizer._set_n_data_points(n_data_points) + self.minimizer._set_n_parameters(n_parameters) + self.minimizer._set_n_free_parameters(n_free_parameters) + self.minimizer._set_degrees_of_freedom(degrees_of_freedom) + self.minimizer._set_covariance_available(value=covariance is not None) + self.minimizer._set_correlation_available(value=correlation_matrix is not None) + self.minimizer._set_runtime_seconds(results.fitting_time) + self.minimizer._set_iterations_performed(results.iterations) + self.minimizer._set_exit_reason(results.message) if correlation_matrix is not None: self._store_correlation_projection( @@ -1335,8 +1391,8 @@ def _store_deterministic_result_projection( source_kind=FitCorrelationSourceEnum.DETERMINISTIC, ) - def _store_bayesian_distribution_cache_projection( - self, + @staticmethod + def _store_posterior_distribution_cache_projection( *, plotter: object, results: BayesianFitResults, @@ -1363,14 +1419,6 @@ def _store_bayesian_distribution_cache_projection( x_values, density_values = density_curve x_array = np.asarray(x_values, dtype=float) density_array = np.asarray(density_values, dtype=float) - cache_index = len(payload) - self.bayesian_distribution_caches.create( - param_unique_name=parameter_name, - x_path=f'/posterior/distribution/{cache_index}/x', - density_path=f'/posterior/distribution/{cache_index}/density', - n_grid=float(x_array.size), - n_draws_cached=float(np.isfinite(flattened_samples[:, parameter_index]).sum()), - ) payload[parameter_name] = { 'x': x_array, 'density': density_array, @@ -1401,7 +1449,7 @@ def _ordered_pair_metadata( x_name, y_name = y_name, x_name return x_index, y_index, x_name, y_name - def _store_one_bayesian_pair_cache_projection( + def _store_one_posterior_pair_cache_projection( self, *, plotter: object, @@ -1410,7 +1458,7 @@ def _store_one_bayesian_pair_cache_projection( pair_metadata: tuple[int, int, str, str], contour_grid_size: int, pair_id: str, - ) -> tuple[str, dict[str, np.ndarray]] | None: + ) -> tuple[str, dict[str, object]] | None: """Store one cached pair surface and return its payload.""" x_index, y_index, x_name, y_name = pair_metadata @@ -1437,33 +1485,23 @@ def _store_one_bayesian_pair_cache_projection( y_grid_array = np.asarray(density_surface[1], dtype=float) density_array = np.asarray(density_surface[2], dtype=float) contour_levels = self._posterior_pair_contour_levels(density_array) - self.bayesian_pair_caches.create( - id=pair_id, - parameter_names=(x_name, y_name), - paths=BayesianPairCachePaths( - x_path=f'/posterior/pairs/{pair_id}/x', - y_path=f'/posterior/pairs/{pair_id}/y', - density_path=f'/posterior/pairs/{pair_id}/density', - contour_level_path=f'/posterior/pairs/{pair_id}/contour_levels', - ), - grid_shape=(float(x_grid_array.size), float(y_grid_array.size)), - n_draws_cached=float(density_samples.shape[0]), - ) return pair_id, { + 'param_unique_name_x': x_name, + 'param_unique_name_y': y_name, 'x': x_grid_array, 'y': y_grid_array, 'density': density_array, 'contour_levels': contour_levels, } - def _store_bayesian_pair_cache_projection( + def _store_posterior_pair_cache_projection( self, *, plotter: object, results: BayesianFitResults, flattened_samples: np.ndarray, parameter_names: list[str], - ) -> dict[str, dict[str, np.ndarray]]: + ) -> dict[str, dict[str, object]]: """Store cached pair-density surfaces in manifests.""" n_parameters = len(parameter_names) if n_parameters <= 1: @@ -1474,10 +1512,10 @@ def _store_bayesian_pair_cache_projection( max_points=plotter._posterior_pair_density_max_points(n_parameters), ) contour_grid_size = plotter._posterior_pair_contour_grid_size(n_parameters) - payload: dict[str, dict[str, np.ndarray]] = {} + payload: dict[str, dict[str, object]] = {} for first_index, second_index in combinations(range(n_parameters), 2): pair_id = str(len(payload) + 1) - cache_projection = self._store_one_bayesian_pair_cache_projection( + cache_projection = self._store_one_posterior_pair_cache_projection( plotter=plotter, results=results, density_samples=density_samples, @@ -1499,9 +1537,10 @@ def _store_bayesian_pair_cache_projection( @staticmethod def _predictive_dataset_payload( summary: PosteriorPredictiveSummary, - ) -> dict[str, np.ndarray]: + ) -> dict[str, object]: """Return persisted predictive arrays for one summary.""" - payload: dict[str, np.ndarray] = { + payload: dict[str, object] = { + 'x_axis_name': summary.x_axis_name, 'x': np.asarray(summary.x, dtype=float), 'best_sample_prediction': np.asarray(summary.best_sample_prediction, dtype=float), } @@ -1517,16 +1556,16 @@ def _predictive_dataset_payload( payload['draws'] = np.asarray(summary.draws, dtype=float) return payload - def _store_bayesian_predictive_projection( + def _store_posterior_predictive_projection( self, *, plotter: object, results: BayesianFitResults, - ) -> dict[str, dict[str, np.ndarray]]: + ) -> dict[str, dict[str, object]]: """ Store posterior predictive summaries into persisted manifests. """ - predictive_payload: dict[str, dict[str, np.ndarray]] = {} + predictive_payload: dict[str, dict[str, object]] = {} for experiment_name in self.project.experiments.names: experiment = self.project.experiments[experiment_name] x_axis, x_axis_name, _, _, _ = plotter._resolve_x_axis(experiment.type, None) @@ -1551,44 +1590,17 @@ def _store_bayesian_predictive_projection( predictive_payload[summary.experiment_name] = self._predictive_dataset_payload( summary, ) - predictive_root = f'/predictive/{summary.experiment_name}' - self.bayesian_predictive_datasets.create( - experiment_name=summary.experiment_name, - x_axis_name=str(x_axis_name), - paths=BayesianPredictiveDatasetPaths( - x_path=f'{predictive_root}/x', - best_sample_prediction_path=(f'{predictive_root}/best_sample_prediction'), - lower_95_path=( - None if summary.lower_95 is None else f'{predictive_root}/lower_95' - ), - upper_95_path=( - None if summary.upper_95 is None else f'{predictive_root}/upper_95' - ), - lower_68_path=( - None if summary.lower_68 is None else f'{predictive_root}/lower_68' - ), - upper_68_path=( - None if summary.upper_68 is None else f'{predictive_root}/upper_68' - ), - draws_path=(None if summary.draws is None else f'{predictive_root}/draws'), - ), - n_x=float(np.asarray(summary.x).size), - n_draws_cached=( - 0.0 if summary.draws is None else float(np.asarray(summary.draws).shape[0]) - ), - ) return predictive_payload - def _store_bayesian_plot_cache_projection(self, results: BayesianFitResults) -> None: + def _store_posterior_plot_cache_projection(self, results: BayesianFitResults) -> None: """Populate persisted Bayesian plot caches.""" posterior_samples = results.posterior_samples if posterior_samples is None: + results.posterior_distribution_caches = {} + results.posterior_pair_caches = {} self._persisted_fit_state_sidecar['distribution_caches'] = {} self._persisted_fit_state_sidecar['pair_caches'] = {} self._persisted_fit_state_sidecar['predictive_datasets'] = {} - self.bayesian_result._set_has_distribution_cache(value=False) - self.bayesian_result._set_has_pair_cache(value=False) - self.bayesian_result._set_has_posterior_predictive(value=False) return flattened_samples = np.asarray(posterior_samples.flattened(), dtype=float) @@ -1598,28 +1610,27 @@ def _store_bayesian_plot_cache_projection(self, results: BayesianFitResults) -> or not parameter_names or flattened_samples.shape[1] != len(parameter_names) ): + results.posterior_distribution_caches = {} + results.posterior_pair_caches = {} self._persisted_fit_state_sidecar['distribution_caches'] = {} self._persisted_fit_state_sidecar['pair_caches'] = {} self._persisted_fit_state_sidecar['predictive_datasets'] = {} - self.bayesian_result._set_has_distribution_cache(value=False) - self.bayesian_result._set_has_pair_cache(value=False) - self.bayesian_result._set_has_posterior_predictive(value=False) return - plotter = self.project.rendering.plotter - distribution_payload = self._store_bayesian_distribution_cache_projection( + plotter = self.project.chart.plotter + distribution_payload = self._store_posterior_distribution_cache_projection( plotter=plotter, results=results, flattened_samples=flattened_samples, parameter_names=parameter_names, ) - pair_payload = self._store_bayesian_pair_cache_projection( + pair_payload = self._store_posterior_pair_cache_projection( plotter=plotter, results=results, flattened_samples=flattened_samples, parameter_names=parameter_names, ) - predictive_payload = self._store_bayesian_predictive_projection( + predictive_payload = self._store_posterior_predictive_projection( plotter=plotter, results=results, ) @@ -1627,11 +1638,10 @@ def _store_bayesian_plot_cache_projection(self, results: BayesianFitResults) -> self._persisted_fit_state_sidecar['distribution_caches'] = distribution_payload self._persisted_fit_state_sidecar['pair_caches'] = pair_payload self._persisted_fit_state_sidecar['predictive_datasets'] = predictive_payload - self.bayesian_result._set_has_distribution_cache(value=bool(distribution_payload)) - self.bayesian_result._set_has_pair_cache(value=bool(pair_payload)) - self.bayesian_result._set_has_posterior_predictive(value=bool(predictive_payload)) + results.posterior_distribution_caches = distribution_payload + results.posterior_pair_caches = pair_payload - def _store_bayesian_posterior_sidecar_projection( + def _store_posterior_samples_sidecar_projection( self, results: BayesianFitResults, ) -> None: @@ -1658,10 +1668,8 @@ def _store_bayesian_posterior_sidecar_projection( ), } - def _store_bayesian_result_projection(self, results: BayesianFitResults) -> None: - """ - Store Bayesian fit-result projections into persisted categories. - """ + def _store_posterior_fit_projection(self, results: BayesianFitResults) -> None: + """Store Bayesian result fields.""" credible_interval_inner = 0.68 credible_interval_outer = 0.95 if len(results.credible_interval_levels) >= _CREDIBLE_INTERVAL_LEVEL_COUNT: @@ -1669,49 +1677,35 @@ def _store_bayesian_result_projection(self, results: BayesianFitResults) -> None credible_interval_outer = float(results.credible_interval_levels[1]) point_estimate_name = results.point_estimate_name or 'best_sample' - sampler_settings = results.sampler_settings convergence = results.convergence_diagnostics - self.bayesian_result._set_sampler_name(results.sampler_name) - self.bayesian_result._set_point_estimate_name(point_estimate_name) - self.bayesian_result._set_success(value=results.success) - self.bayesian_result._set_sampler_completed(value=results.sampler_completed) - self.bayesian_result._set_best_log_posterior(results.best_log_posterior) - self.bayesian_result._set_credible_interval_inner(credible_interval_inner) - self.bayesian_result._set_credible_interval_outer(credible_interval_outer) - self.bayesian_result._set_has_posterior_samples( - value=results.posterior_samples is not None - ) - self.bayesian_result._set_has_distribution_cache(value=False) - self.bayesian_result._set_has_pair_cache(value=False) - self.bayesian_result._set_has_posterior_predictive(value=False) - self.bayesian_result._set_sidecar_file('results.h5') - self._store_bayesian_posterior_sidecar_projection(results) - - self.bayesian_sampler._set_steps(int(sampler_settings.get('steps', 0))) - self.bayesian_sampler._set_burn(int(sampler_settings.get('burn', 0))) - self.bayesian_sampler._set_thin(int(sampler_settings.get('thin', 0))) - self.bayesian_sampler._set_pop(int(sampler_settings.get('pop', 0))) - self.bayesian_sampler._set_parallel(int(sampler_settings.get('parallel', 0))) - self.bayesian_sampler._set_init(str(sampler_settings.get('init', ''))) - random_seed = sampler_settings.get('random_seed') - self.bayesian_sampler._set_random_seed(None if random_seed is None else int(random_seed)) - - self.bayesian_convergence._set_converged(value=bool(convergence.get('converged', False))) - self.bayesian_convergence._set_max_r_hat(convergence.get('max_r_hat')) - self.bayesian_convergence._set_min_ess_bulk(convergence.get('min_ess_bulk')) - self.bayesian_convergence._set_n_draws(int(convergence.get('n_draws', 0))) - self.bayesian_convergence._set_n_chains(int(convergence.get('n_chains', 0))) - self.bayesian_convergence._set_n_parameters(int(convergence.get('n_parameters', 0))) - + self.minimizer._set_runtime_seconds(results.fitting_time) + self.minimizer._set_point_estimate_name(point_estimate_name) + self.minimizer._set_sampler_completed(value=results.sampler_completed) + self.minimizer._set_best_log_posterior(results.best_log_posterior) + self.minimizer._set_credible_interval_inner(credible_interval_inner) + self.minimizer._set_credible_interval_outer(credible_interval_outer) + self.minimizer._set_gelman_rubin_max(convergence.get('max_r_hat')) + self.minimizer._set_effective_sample_size_min(convergence.get('min_ess_bulk')) + self.minimizer._set_acceptance_rate_mean(convergence.get('acceptance_rate_mean')) + self._store_posterior_samples_sidecar_projection(results) + + live_parameters = { + parameter.unique_name: parameter + for parameter in results.parameters + if isinstance(parameter, Parameter) + } for summary in results.posterior_parameter_summaries: - self.bayesian_parameter_posteriors.create(summary=summary) + self.fit_parameters.set_posterior_summary(summary) + parameter = live_parameters.get(summary.unique_name) + if parameter is not None: + parameter._set_posterior(summary) posterior_samples = results.posterior_samples if posterior_samples is None: return - self._store_bayesian_plot_cache_projection(results) + self._store_posterior_plot_cache_projection(results) if len(posterior_samples.parameter_names) <= 1: return @@ -1738,14 +1732,14 @@ def _store_fit_result_projection( results, result_kind=FitResultKindEnum.BAYESIAN, ) - self._store_bayesian_result_projection(results) + self._store_posterior_fit_projection(results) return self._store_common_fit_result_projection( results, result_kind=FitResultKindEnum.DETERMINISTIC, ) - self._store_deterministic_result_projection( + self._store_least_squares_result_projection( results, experiments=experiments, fitted_parameters=fitted_parameters, @@ -1782,10 +1776,12 @@ def _prepare_fit_run(self) -> tuple[VerbosityEnum, object, object] | None: log.warning('No experiments found in the project. Cannot run fit.') return None + self._warn_results_sidecar_overwrite() + # Apply constraints before fitting so that user-constrained # parameters are marked and excluded from the free parameter # list built by the fitter. - self._sync_live_minimizer_from_persisted_fit_state() + self._sync_engine_from_minimizer_category() self._update_categories() return verb, structures, experiments @@ -1836,6 +1832,7 @@ def _run_sequential(self) -> None: self._set_fitting_mode_type(FitModeEnum.SEQUENTIAL.value) self._update_categories() + self._warn_results_sidecar_overwrite() self._clear_persisted_fit_state() max_workers_value = self._sequential_fit.max_workers.value @@ -1909,7 +1906,7 @@ def _fit_joint( analysis=self, verbosity=verb, use_physical_limits=use_physical_limits, - random_seed=random_seed, + random_seed=self._resolved_fit_random_seed(random_seed), ) # After fitting, get the results @@ -1961,7 +1958,7 @@ def _fit_single( analysis=self, verbosity=verb, use_physical_limits=use_physical_limits, - random_seed=random_seed, + random_seed=self._resolved_fit_random_seed(random_seed), ) # After fitting, snapshot parameter values before diff --git a/src/easydiffraction/analysis/categories/__init__.py b/src/easydiffraction/analysis/categories/__init__.py index 743e0ff02..d6dcda46b 100644 --- a/src/easydiffraction/analysis/categories/__init__.py +++ b/src/easydiffraction/analysis/categories/__init__.py @@ -3,32 +3,8 @@ from easydiffraction.analysis.categories.aliases import Alias from easydiffraction.analysis.categories.aliases import Aliases -from easydiffraction.analysis.categories.bayesian_convergence import BayesianConvergence -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCacheItem, -) -from easydiffraction.analysis.categories.bayesian_distribution_caches import ( - BayesianDistributionCaches, -) -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCacheItem -from easydiffraction.analysis.categories.bayesian_pair_caches import BayesianPairCaches -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriorItem, -) -from easydiffraction.analysis.categories.bayesian_parameter_posteriors import ( - BayesianParameterPosteriors, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasetItem, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets import ( - BayesianPredictiveDatasets, -) -from easydiffraction.analysis.categories.bayesian_result import BayesianResult -from easydiffraction.analysis.categories.bayesian_sampler import BayesianSampler from easydiffraction.analysis.categories.constraints import Constraint from easydiffraction.analysis.categories.constraints import Constraints -from easydiffraction.analysis.categories.deterministic_result import DeterministicResult from easydiffraction.analysis.categories.fit_parameter_correlations import ( FitParameterCorrelationItem, ) @@ -36,9 +12,22 @@ from easydiffraction.analysis.categories.fit_parameters import FitParameterItem from easydiffraction.analysis.categories.fit_parameters import FitParameters from easydiffraction.analysis.categories.fit_result import FitResult -from easydiffraction.analysis.categories.fitting import Fitting +from easydiffraction.analysis.categories.fitting_mode import FittingMode +from easydiffraction.analysis.categories.fitting_mode import FittingModeFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection from easydiffraction.analysis.categories.joint_fit import JointFitItem +from easydiffraction.analysis.categories.minimizer import BayesianMinimizerBase +from easydiffraction.analysis.categories.minimizer import BumpsAmoebaMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsDeMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsDreamMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsLmMinimizer +from easydiffraction.analysis.categories.minimizer import BumpsMinimizer +from easydiffraction.analysis.categories.minimizer import DfolsMinimizer +from easydiffraction.analysis.categories.minimizer import LeastSquaresMinimizerBase +from easydiffraction.analysis.categories.minimizer import LmfitLeastsqMinimizer +from easydiffraction.analysis.categories.minimizer import LmfitLeastSquaresMinimizer +from easydiffraction.analysis.categories.minimizer import LmfitMinimizer +from easydiffraction.analysis.categories.minimizer import MinimizerCategoryFactory from easydiffraction.analysis.categories.sequential_fit import SequentialFit from easydiffraction.analysis.categories.sequential_fit_extract import ( SequentialFitExtractCollection, diff --git a/src/easydiffraction/analysis/categories/bayesian_convergence/__init__.py b/src/easydiffraction/analysis/categories/bayesian_convergence/__init__.py deleted file mode 100644 index e9acbd717..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_convergence/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_convergence.default import BayesianConvergence -from easydiffraction.analysis.categories.bayesian_convergence.factory import ( - BayesianConvergenceFactory, -) diff --git a/src/easydiffraction/analysis/categories/bayesian_convergence/default.py b/src/easydiffraction/analysis/categories/bayesian_convergence/default.py deleted file mode 100644 index 48d00fa41..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_convergence/default.py +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian convergence diagnostics category.""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.bayesian_convergence.factory import ( - BayesianConvergenceFactory, -) -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import BoolDescriptor -from easydiffraction.core.variable import IntegerDescriptor -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -@BayesianConvergenceFactory.register -class BayesianConvergence(CategoryItem): - """Persisted Bayesian convergence diagnostics.""" - - _category_code = 'bayesian_convergence' - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian convergence diagnostics', - ) - - def __init__(self) -> None: - super().__init__() - self._converged = BoolDescriptor( - name='converged', - description='Whether the Bayesian fit met convergence criteria.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_convergence.converged']), - ) - self._max_r_hat = NumericDescriptor( - name='max_r_hat', - description='Maximum rank-normalized split-R-hat across parameters.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_convergence.max_r_hat']), - ) - self._min_ess_bulk = NumericDescriptor( - name='min_ess_bulk', - description='Minimum bulk effective sample size across parameters.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_convergence.min_ess_bulk']), - ) - self._n_draws = IntegerDescriptor( - name='n_draws', - description='Number of stored posterior draws.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_convergence.n_draws']), - ) - self._n_chains = IntegerDescriptor( - name='n_chains', - description='Number of stored posterior chains.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_convergence.n_chains']), - ) - self._n_parameters = IntegerDescriptor( - name='n_parameters', - description='Number of sampled parameters.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_convergence.n_parameters']), - ) - - @property - def converged(self) -> BoolDescriptor: - """Whether the Bayesian fit met convergence criteria.""" - return self._converged - - def _set_converged(self, *, value: bool) -> None: - """Set the convergence flag for internal callers.""" - self._converged.value = value - - @property - def max_r_hat(self) -> NumericDescriptor: - """Maximum rank-normalized split-R-hat across parameters.""" - return self._max_r_hat - - def _set_max_r_hat(self, value: float | None) -> None: - """Set the maximum R-hat for internal callers.""" - self._max_r_hat.value = value - - @property - def min_ess_bulk(self) -> NumericDescriptor: - """Minimum bulk effective sample size across parameters.""" - return self._min_ess_bulk - - def _set_min_ess_bulk(self, value: float | None) -> None: - """Set the minimum ESS bulk for internal callers.""" - self._min_ess_bulk.value = value - - @property - def n_draws(self) -> IntegerDescriptor: - """Number of stored posterior draws.""" - return self._n_draws - - def _set_n_draws(self, value: int) -> None: - """Set the draw count for internal callers.""" - self._n_draws.value = value - - @property - def n_chains(self) -> IntegerDescriptor: - """Number of stored posterior chains.""" - return self._n_chains - - def _set_n_chains(self, value: int) -> None: - """Set the chain count for internal callers.""" - self._n_chains.value = value - - @property - def n_parameters(self) -> IntegerDescriptor: - """Number of sampled parameters.""" - return self._n_parameters - - def _set_n_parameters(self, value: int) -> None: - """Set the sampled-parameter count for internal callers.""" - self._n_parameters.value = value diff --git a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/__init__.py b/src/easydiffraction/analysis/categories/bayesian_distribution_caches/__init__.py deleted file mode 100644 index 4ecf63f0b..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_distribution_caches.default import ( - BayesianDistributionCacheItem, -) -from easydiffraction.analysis.categories.bayesian_distribution_caches.default import ( - BayesianDistributionCaches, -) -from easydiffraction.analysis.categories.bayesian_distribution_caches.factory import ( - BayesianDistributionCachesFactory, -) diff --git a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/default.py b/src/easydiffraction/analysis/categories/bayesian_distribution_caches/default.py deleted file mode 100644 index 41183dc39..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/default.py +++ /dev/null @@ -1,151 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian distribution-cache manifest rows.""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.bayesian_distribution_caches.factory import ( - BayesianDistributionCachesFactory, -) -from easydiffraction.core.category import CategoryCollection -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import RegexValidator -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -class BayesianDistributionCacheItem(CategoryItem): - """Single persisted Bayesian distribution-cache manifest row.""" - - _category_code = 'bayesian_distribution_cache' - _category_entry_name = 'param_unique_name' - - def __init__(self) -> None: - super().__init__() - self._param_unique_name = StringDescriptor( - name='param_unique_name', - description='Unique parameter name for the cached distribution.', - value_spec=AttributeSpec( - default='_', - validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_.]*$'), - ), - cif_handler=CifHandler(names=['_bayesian_distribution_cache.param_unique_name']), - ) - self._x_path = StringDescriptor( - name='x_path', - description='HDF5 dataset path for the distribution x-grid.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_distribution_cache.x_path']), - ) - self._density_path = StringDescriptor( - name='density_path', - description='HDF5 dataset path for the cached density values.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_distribution_cache.density_path']), - ) - self._n_grid = NumericDescriptor( - name='n_grid', - description='Number of grid points in the cached distribution.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_distribution_cache.n_grid']), - ) - self._n_draws_cached = NumericDescriptor( - name='n_draws_cached', - description='Number of draws summarized into the cached distribution.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_distribution_cache.n_draws_cached']), - ) - - @property - def param_unique_name(self) -> StringDescriptor: - """Unique parameter name for the cached distribution.""" - return self._param_unique_name - - def _set_param_unique_name(self, value: str) -> None: - """Set the unique parameter name for internal callers.""" - self._param_unique_name.value = value - - @property - def x_path(self) -> StringDescriptor: - """HDF5 dataset path for the distribution x-grid.""" - return self._x_path - - def _set_x_path(self, value: str) -> None: - """Set the x-grid dataset path for internal callers.""" - self._x_path.value = value - - @property - def density_path(self) -> StringDescriptor: - """HDF5 dataset path for the cached density values.""" - return self._density_path - - def _set_density_path(self, value: str) -> None: - """Set the density dataset path for internal callers.""" - self._density_path.value = value - - @property - def n_grid(self) -> NumericDescriptor: - """Number of grid points in the cached distribution.""" - return self._n_grid - - def _set_n_grid(self, value: float) -> None: - """Set the grid-size count for internal callers.""" - self._n_grid.value = value - - @property - def n_draws_cached(self) -> NumericDescriptor: - """Number of draws summarized into the cached distribution.""" - return self._n_draws_cached - - def _set_n_draws_cached(self, value: float) -> None: - """Set the cached-draw count for internal callers.""" - self._n_draws_cached.value = value - - -@BayesianDistributionCachesFactory.register -class BayesianDistributionCaches(CategoryCollection): - """Collection of persisted Bayesian distribution-cache manifests.""" - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian distribution-cache manifests', - ) - - def __init__(self) -> None: - super().__init__(item_type=BayesianDistributionCacheItem) - - def create( - self, - *, - param_unique_name: str, - x_path: str, - density_path: str, - n_grid: float, - n_draws_cached: float, - ) -> None: - """ - Create a persisted Bayesian distribution-cache manifest row. - - Parameters - ---------- - param_unique_name : str - Unique parameter name for the cached distribution. - x_path : str - HDF5 dataset path for the distribution x-grid. - density_path : str - HDF5 dataset path for the cached density values. - n_grid : float - Number of grid points in the cached distribution. - n_draws_cached : float - Number of draws summarized into the cached distribution. - """ - item = BayesianDistributionCacheItem() - item._set_param_unique_name(param_unique_name) - item._set_x_path(x_path) - item._set_density_path(density_path) - item._set_n_grid(n_grid) - item._set_n_draws_cached(n_draws_cached) - self.add(item) diff --git a/src/easydiffraction/analysis/categories/bayesian_pair_caches/__init__.py b/src/easydiffraction/analysis/categories/bayesian_pair_caches/__init__.py deleted file mode 100644 index edc955fa8..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_pair_caches/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_pair_caches.default import BayesianPairCacheItem -from easydiffraction.analysis.categories.bayesian_pair_caches.default import BayesianPairCaches -from easydiffraction.analysis.categories.bayesian_pair_caches.factory import ( - BayesianPairCachesFactory, -) diff --git a/src/easydiffraction/analysis/categories/bayesian_pair_caches/default.py b/src/easydiffraction/analysis/categories/bayesian_pair_caches/default.py deleted file mode 100644 index 937c77a5e..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_pair_caches/default.py +++ /dev/null @@ -1,267 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian pair-cache manifest rows.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from easydiffraction.analysis.categories.bayesian_pair_caches.factory import ( - BayesianPairCachesFactory, -) -from easydiffraction.core.category import CategoryCollection -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import RegexValidator -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -def _normalized_parameter_pair( - param_unique_name_x: str, - param_unique_name_y: str, -) -> tuple[str, str]: - """Return a stable ordering for a cached parameter pair.""" - if param_unique_name_x <= param_unique_name_y: - return param_unique_name_x, param_unique_name_y - return param_unique_name_y, param_unique_name_x - - -@dataclass(frozen=True, slots=True) -class BayesianPairCachePaths: - """HDF5 dataset paths for one persisted pair cache.""" - - x_path: str - y_path: str - density_path: str - contour_level_path: str - - -class BayesianPairCacheItem(CategoryItem): - """Single persisted Bayesian pair-cache manifest row.""" - - _category_code = 'bayesian_pair_cache' - _category_entry_name = 'id' - - def __init__(self) -> None: - super().__init__() - self._param_unique_name_x = StringDescriptor( - name='param_unique_name_x', - description='First unique parameter name in the cached pair.', - value_spec=AttributeSpec( - default='_', - validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_.]*$'), - ), - cif_handler=CifHandler(names=['_bayesian_pair_cache.param_unique_name_x']), - ) - self._param_unique_name_y = StringDescriptor( - name='param_unique_name_y', - description='Second unique parameter name in the cached pair.', - value_spec=AttributeSpec( - default='_', - validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_.]*$'), - ), - cif_handler=CifHandler(names=['_bayesian_pair_cache.param_unique_name_y']), - ) - self._id = StringDescriptor( - name='id', - description='Stable identifier for the cached parameter pair.', - value_spec=AttributeSpec( - default='_', - validator=RegexValidator(pattern=r'^[A-Za-z0-9_.:-]+$'), - ), - cif_handler=CifHandler(names=['_bayesian_pair_cache.id']), - ) - self._x_path = StringDescriptor( - name='x_path', - description='HDF5 dataset path for the pair-cache x-grid.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_pair_cache.x_path']), - ) - self._y_path = StringDescriptor( - name='y_path', - description='HDF5 dataset path for the pair-cache y-grid.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_pair_cache.y_path']), - ) - self._density_path = StringDescriptor( - name='density_path', - description='HDF5 dataset path for the pair-cache density grid.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_pair_cache.density_path']), - ) - self._contour_level_path = StringDescriptor( - name='contour_level_path', - description='HDF5 dataset path for cached contour levels.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_pair_cache.contour_level_path']), - ) - self._n_grid_x = NumericDescriptor( - name='n_grid_x', - description='Number of x-grid points in the cached pair.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_pair_cache.n_grid_x']), - ) - self._n_grid_y = NumericDescriptor( - name='n_grid_y', - description='Number of y-grid points in the cached pair.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_pair_cache.n_grid_y']), - ) - self._n_draws_cached = NumericDescriptor( - name='n_draws_cached', - description='Number of draws summarized into the cached pair.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_pair_cache.n_draws_cached']), - ) - - @property - def param_unique_name_x(self) -> StringDescriptor: - """First unique parameter name in the cached pair.""" - return self._param_unique_name_x - - def _set_param_unique_name_x(self, value: str) -> None: - """Set the first unique parameter name for internal callers.""" - self._param_unique_name_x.value = value - - @property - def param_unique_name_y(self) -> StringDescriptor: - """Second unique parameter name in the cached pair.""" - return self._param_unique_name_y - - def _set_param_unique_name_y(self, value: str) -> None: - """Set the second unique parameter name for internal callers.""" - self._param_unique_name_y.value = value - - @property - def id(self) -> StringDescriptor: - """Stable identifier for the cached parameter pair.""" - return self._id - - def _set_id(self, value: str) -> None: - """Set the pair-cache id for internal callers.""" - self._id.value = value - - @property - def x_path(self) -> StringDescriptor: - """HDF5 dataset path for the pair-cache x-grid.""" - return self._x_path - - def _set_x_path(self, value: str) -> None: - """Set the pair-cache x-grid path for internal callers.""" - self._x_path.value = value - - @property - def y_path(self) -> StringDescriptor: - """HDF5 dataset path for the pair-cache y-grid.""" - return self._y_path - - def _set_y_path(self, value: str) -> None: - """Set the pair-cache y-grid path for internal callers.""" - self._y_path.value = value - - @property - def density_path(self) -> StringDescriptor: - """HDF5 dataset path for the pair-cache density grid.""" - return self._density_path - - def _set_density_path(self, value: str) -> None: - """Set the pair-cache density path for internal callers.""" - self._density_path.value = value - - @property - def contour_level_path(self) -> StringDescriptor: - """HDF5 dataset path for cached contour levels.""" - return self._contour_level_path - - def _set_contour_level_path(self, value: str) -> None: - """Set the contour-level path for internal callers.""" - self._contour_level_path.value = value - - @property - def n_grid_x(self) -> NumericDescriptor: - """Number of x-grid points in the cached pair.""" - return self._n_grid_x - - def _set_n_grid_x(self, value: float) -> None: - """Set the x-grid size for internal callers.""" - self._n_grid_x.value = value - - @property - def n_grid_y(self) -> NumericDescriptor: - """Number of y-grid points in the cached pair.""" - return self._n_grid_y - - def _set_n_grid_y(self, value: float) -> None: - """Set the y-grid size for internal callers.""" - self._n_grid_y.value = value - - @property - def n_draws_cached(self) -> NumericDescriptor: - """Number of draws summarized into the cached pair.""" - return self._n_draws_cached - - def _set_n_draws_cached(self, value: float) -> None: - """Set the cached-draw count for internal callers.""" - self._n_draws_cached.value = value - - -@BayesianPairCachesFactory.register -class BayesianPairCaches(CategoryCollection): - """Collection of persisted Bayesian pair-cache manifests.""" - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian pair-cache manifests', - ) - - def __init__(self) -> None: - super().__init__(item_type=BayesianPairCacheItem) - - def create( - self, - *, - parameter_names: tuple[str, str], - paths: BayesianPairCachePaths, - grid_shape: tuple[float, float], - n_draws_cached: float, - id: str | None = None, - ) -> None: - """ - Create a persisted Bayesian pair-cache manifest row. - - Parameters - ---------- - parameter_names : tuple[str, str] - Unique parameter names for the cached pair. - paths : BayesianPairCachePaths - HDF5 dataset paths for the cached pair payloads. - grid_shape : tuple[float, float] - Number of x-grid and y-grid points in the cached pair. - n_draws_cached : float - Number of draws summarized into the cached pair. - id : str | None, default=None - Explicit persisted row id. When omitted, a simple sequential - identifier is generated. - """ - param_unique_name_x, param_unique_name_y = parameter_names - normalized_x, normalized_y = _normalized_parameter_pair( - param_unique_name_x, - param_unique_name_y, - ) - n_grid_x, n_grid_y = grid_shape - item = BayesianPairCacheItem() - item._set_param_unique_name_x(normalized_x) - item._set_param_unique_name_y(normalized_y) - item._set_x_path(paths.x_path) - item._set_y_path(paths.y_path) - item._set_density_path(paths.density_path) - item._set_contour_level_path(paths.contour_level_path) - item._set_n_grid_x(n_grid_x) - item._set_n_grid_y(n_grid_y) - item._set_n_draws_cached(n_draws_cached) - resolved_id = id or str(len(self) + 1) - item._set_id(resolved_id) - self.add(item) diff --git a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/__init__.py b/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/__init__.py deleted file mode 100644 index 10ec76953..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_parameter_posteriors.default import ( - BayesianParameterPosteriorItem, -) -from easydiffraction.analysis.categories.bayesian_parameter_posteriors.default import ( - BayesianParameterPosteriors, -) -from easydiffraction.analysis.categories.bayesian_parameter_posteriors.factory import ( - BayesianParameterPosteriorsFactory, -) diff --git a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/default.py b/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/default.py deleted file mode 100644 index ea234f0ec..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/default.py +++ /dev/null @@ -1,242 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian parameter posterior summary rows.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from easydiffraction.analysis.categories.bayesian_parameter_posteriors.factory import ( - BayesianParameterPosteriorsFactory, -) -from easydiffraction.core.category import CategoryCollection -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import RegexValidator -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - -if TYPE_CHECKING: - from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary - - -class BayesianParameterPosteriorItem(CategoryItem): - """Single persisted Bayesian parameter posterior summary row.""" - - _category_code = 'bayesian_parameter_posterior' - _category_entry_name = 'unique_name' - - def __init__(self) -> None: - super().__init__() - self._unique_name = StringDescriptor( - name='unique_name', - description='Unique EasyDiffraction parameter name.', - value_spec=AttributeSpec( - default='_', - validator=RegexValidator(pattern=r'^[A-Za-z_][A-Za-z0-9_.]*$'), - ), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.unique_name']), - ) - self._display_name = StringDescriptor( - name='display_name', - description='Human-readable parameter label.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.display_name']), - ) - self._best_sample_value = NumericDescriptor( - name='best_sample_value', - description='Committed sampled parameter value.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.best_sample_value']), - ) - self._median = NumericDescriptor( - name='median', - description='Posterior median value.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.median']), - ) - self._uncertainty = NumericDescriptor( - name='uncertainty', - description='Posterior standard deviation.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.uncertainty']), - ) - self._interval_68_lower = NumericDescriptor( - name='interval_68_lower', - description='Lower bound of the 68% credible interval.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.interval_68_lower']), - ) - self._interval_68_upper = NumericDescriptor( - name='interval_68_upper', - description='Upper bound of the 68% credible interval.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.interval_68_upper']), - ) - self._interval_95_lower = NumericDescriptor( - name='interval_95_lower', - description='Lower bound of the 95% credible interval.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.interval_95_lower']), - ) - self._interval_95_upper = NumericDescriptor( - name='interval_95_upper', - description='Upper bound of the 95% credible interval.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.interval_95_upper']), - ) - self._ess_bulk = NumericDescriptor( - name='ess_bulk', - description='Bulk effective sample size when available.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.ess_bulk']), - ) - self._r_hat = NumericDescriptor( - name='r_hat', - description='Rank-normalized split-R-hat when available.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_parameter_posterior.r_hat']), - ) - - @property - def unique_name(self) -> StringDescriptor: - """Unique EasyDiffraction parameter name.""" - return self._unique_name - - def _set_unique_name(self, value: str) -> None: - """Set the unique parameter name for internal callers.""" - self._unique_name.value = value - - @property - def display_name(self) -> StringDescriptor: - """Human-readable parameter label.""" - return self._display_name - - def _set_display_name(self, value: str) -> None: - """Set the display name for internal callers.""" - self._display_name.value = value - - @property - def best_sample_value(self) -> NumericDescriptor: - """Committed sampled parameter value.""" - return self._best_sample_value - - def _set_best_sample_value(self, value: float | None) -> None: - """Set the best sampled parameter value for internal callers.""" - self._best_sample_value.value = value - - @property - def median(self) -> NumericDescriptor: - """Posterior median value.""" - return self._median - - def _set_median(self, value: float | None) -> None: - """Set the posterior median for internal callers.""" - self._median.value = value - - @property - def uncertainty(self) -> NumericDescriptor: - """Posterior standard deviation.""" - return self._uncertainty - - def _set_uncertainty(self, value: float | None) -> None: - """Set the posterior uncertainty for internal callers.""" - self._uncertainty.value = value - - @property - def interval_68_lower(self) -> NumericDescriptor: - """Lower bound of the 68% credible interval.""" - return self._interval_68_lower - - def _set_interval_68_lower(self, value: float | None) -> None: - """Set the 68% interval lower bound for internal callers.""" - self._interval_68_lower.value = value - - @property - def interval_68_upper(self) -> NumericDescriptor: - """Upper bound of the 68% credible interval.""" - return self._interval_68_upper - - def _set_interval_68_upper(self, value: float | None) -> None: - """Set the 68% interval upper bound for internal callers.""" - self._interval_68_upper.value = value - - @property - def interval_95_lower(self) -> NumericDescriptor: - """Lower bound of the 95% credible interval.""" - return self._interval_95_lower - - def _set_interval_95_lower(self, value: float | None) -> None: - """Set the 95% interval lower bound for internal callers.""" - self._interval_95_lower.value = value - - @property - def interval_95_upper(self) -> NumericDescriptor: - """Upper bound of the 95% credible interval.""" - return self._interval_95_upper - - def _set_interval_95_upper(self, value: float | None) -> None: - """Set the 95% interval upper bound for internal callers.""" - self._interval_95_upper.value = value - - @property - def ess_bulk(self) -> NumericDescriptor: - """Bulk effective sample size when available.""" - return self._ess_bulk - - def _set_ess_bulk(self, value: float | None) -> None: - """Set the ESS bulk value for internal callers.""" - self._ess_bulk.value = value - - @property - def r_hat(self) -> NumericDescriptor: - """Rank-normalized split-R-hat when available.""" - return self._r_hat - - def _set_r_hat(self, value: float | None) -> None: - """Set the R-hat value for internal callers.""" - self._r_hat.value = value - - -@BayesianParameterPosteriorsFactory.register -class BayesianParameterPosteriors(CategoryCollection): - """ - Collection of persisted Bayesian parameter posterior summaries. - """ - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian parameter posterior summaries', - ) - - def __init__(self) -> None: - super().__init__(item_type=BayesianParameterPosteriorItem) - - def create( - self, - *, - summary: PosteriorParameterSummary, - ) -> None: - """ - Create a persisted Bayesian parameter posterior summary row. - - Parameters - ---------- - summary : PosteriorParameterSummary - Runtime posterior summary to persist. - """ - item = BayesianParameterPosteriorItem() - item._set_unique_name(summary.unique_name) - item._set_display_name(summary.display_name) - item._set_best_sample_value(summary.best_sample_value) - item._set_median(summary.median) - item._set_uncertainty(summary.standard_deviation) - item._set_interval_68_lower(summary.interval_68[0]) - item._set_interval_68_upper(summary.interval_68[1]) - item._set_interval_95_lower(summary.interval_95[0]) - item._set_interval_95_upper(summary.interval_95[1]) - item._set_ess_bulk(summary.ess_bulk) - item._set_r_hat(summary.r_hat) - self.add(item) diff --git a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/factory.py b/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/factory.py deleted file mode 100644 index 54eef2b83..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_parameter_posteriors/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-parameter-posteriors factory.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class BayesianParameterPosteriorsFactory(FactoryBase): - """Create Bayesian-parameter-posterior collections by tag.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/__init__.py b/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/__init__.py deleted file mode 100644 index f706de4fc..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasetItem, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasets, -) -from easydiffraction.analysis.categories.bayesian_predictive_datasets.factory import ( - BayesianPredictiveDatasetsFactory, -) diff --git a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/default.py b/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/default.py deleted file mode 100644 index 9bb8acf00..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/default.py +++ /dev/null @@ -1,260 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian predictive-dataset manifest rows.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from easydiffraction.analysis.categories.bayesian_predictive_datasets.factory import ( - BayesianPredictiveDatasetsFactory, -) -from easydiffraction.core.category import CategoryCollection -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -@dataclass(frozen=True, slots=True) -class BayesianPredictiveDatasetPaths: - """HDF5 dataset paths for one predictive dataset.""" - - x_path: str - best_sample_prediction_path: str - lower_95_path: str | None = None - upper_95_path: str | None = None - lower_68_path: str | None = None - upper_68_path: str | None = None - draws_path: str | None = None - - -class BayesianPredictiveDatasetItem(CategoryItem): - """Single persisted Bayesian predictive-dataset manifest row.""" - - _category_code = 'bayesian_predictive_dataset' - _category_entry_name = 'experiment_name' - - def __init__(self) -> None: - super().__init__() - self._experiment_name = StringDescriptor( - name='experiment_name', - description='Experiment name for the cached predictive dataset.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.experiment_name']), - ) - self._x_axis_name = StringDescriptor( - name='x_axis_name', - description='Name of the predictive dataset x-axis.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.x_axis_name']), - ) - self._x_path = StringDescriptor( - name='x_path', - description='HDF5 dataset path for the predictive x-axis values.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.x_path']), - ) - self._best_sample_prediction_path = StringDescriptor( - name='best_sample_prediction_path', - description='HDF5 dataset path for the committed predictive curve.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler( - names=['_bayesian_predictive_dataset.best_sample_prediction_path'] - ), - ) - self._lower_95_path = StringDescriptor( - name='lower_95_path', - description='HDF5 dataset path for the lower 95% predictive band.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.lower_95_path']), - ) - self._upper_95_path = StringDescriptor( - name='upper_95_path', - description='HDF5 dataset path for the upper 95% predictive band.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.upper_95_path']), - ) - self._lower_68_path = StringDescriptor( - name='lower_68_path', - description='HDF5 dataset path for the lower 68% predictive band.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.lower_68_path']), - ) - self._upper_68_path = StringDescriptor( - name='upper_68_path', - description='HDF5 dataset path for the upper 68% predictive band.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.upper_68_path']), - ) - self._draws_path = StringDescriptor( - name='draws_path', - description='HDF5 dataset path for cached predictive draws.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.draws_path']), - ) - self._n_x = NumericDescriptor( - name='n_x', - description='Number of x-axis points in the cached predictive dataset.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.n_x']), - ) - self._n_draws_cached = NumericDescriptor( - name='n_draws_cached', - description='Number of cached predictive draws.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_predictive_dataset.n_draws_cached']), - ) - - @property - def experiment_name(self) -> StringDescriptor: - """Experiment name for the cached predictive dataset.""" - return self._experiment_name - - def _set_experiment_name(self, value: str) -> None: - """Set the experiment name for internal callers.""" - self._experiment_name.value = value - - @property - def x_axis_name(self) -> StringDescriptor: - """Name of the predictive dataset x-axis.""" - return self._x_axis_name - - def _set_x_axis_name(self, value: str) -> None: - """Set the x-axis name for internal callers.""" - self._x_axis_name.value = value - - @property - def x_path(self) -> StringDescriptor: - """HDF5 dataset path for the predictive x-axis values.""" - return self._x_path - - def _set_x_path(self, value: str) -> None: - """Set the predictive x-axis path for internal callers.""" - self._x_path.value = value - - @property - def best_sample_prediction_path(self) -> StringDescriptor: - """HDF5 dataset path for the committed predictive curve.""" - return self._best_sample_prediction_path - - def _set_best_sample_prediction_path(self, value: str) -> None: - """Set the best-sample prediction path for internal callers.""" - self._best_sample_prediction_path.value = value - - @property - def lower_95_path(self) -> StringDescriptor: - """HDF5 dataset path for the lower 95% predictive band.""" - return self._lower_95_path - - def _set_lower_95_path(self, value: str | None) -> None: - """Set the lower-95 path for internal callers.""" - self._lower_95_path.value = value - - @property - def upper_95_path(self) -> StringDescriptor: - """HDF5 dataset path for the upper 95% predictive band.""" - return self._upper_95_path - - def _set_upper_95_path(self, value: str | None) -> None: - """Set the upper-95 path for internal callers.""" - self._upper_95_path.value = value - - @property - def lower_68_path(self) -> StringDescriptor: - """HDF5 dataset path for the lower 68% predictive band.""" - return self._lower_68_path - - def _set_lower_68_path(self, value: str | None) -> None: - """Set the lower-68 path for internal callers.""" - self._lower_68_path.value = value - - @property - def upper_68_path(self) -> StringDescriptor: - """HDF5 dataset path for the upper 68% predictive band.""" - return self._upper_68_path - - def _set_upper_68_path(self, value: str | None) -> None: - """Set the upper-68 path for internal callers.""" - self._upper_68_path.value = value - - @property - def draws_path(self) -> StringDescriptor: - """HDF5 dataset path for cached predictive draws.""" - return self._draws_path - - def _set_draws_path(self, value: str | None) -> None: - """Set the predictive-draws path for internal callers.""" - self._draws_path.value = value - - @property - def n_x(self) -> NumericDescriptor: - """Number of x-axis points in the cached predictive dataset.""" - return self._n_x - - def _set_n_x(self, value: float) -> None: - """Set the predictive x-axis size for internal callers.""" - self._n_x.value = value - - @property - def n_draws_cached(self) -> NumericDescriptor: - """Number of cached predictive draws.""" - return self._n_draws_cached - - def _set_n_draws_cached(self, value: float) -> None: - """Set the cached predictive-draw count for internal callers.""" - self._n_draws_cached.value = value - - -@BayesianPredictiveDatasetsFactory.register -class BayesianPredictiveDatasets(CategoryCollection): - """Collection of persisted Bayesian predictive-dataset manifests.""" - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian predictive-dataset manifests', - ) - - def __init__(self) -> None: - super().__init__(item_type=BayesianPredictiveDatasetItem) - - def create( - self, - *, - experiment_name: str, - x_axis_name: str, - paths: BayesianPredictiveDatasetPaths, - n_x: float, - n_draws_cached: float, - ) -> None: - """ - Create a persisted Bayesian predictive-dataset manifest row. - - Parameters - ---------- - experiment_name : str - Experiment name for the cached predictive dataset. - x_axis_name : str - Name of the predictive dataset x-axis. - paths : BayesianPredictiveDatasetPaths - HDF5 dataset paths for the predictive dataset payloads. - n_x : float - Number of x-axis points in the cached predictive dataset. - n_draws_cached : float - Number of cached predictive draws. - """ - item = BayesianPredictiveDatasetItem() - item._set_experiment_name(experiment_name) - item._set_x_axis_name(x_axis_name) - item._set_x_path(paths.x_path) - item._set_best_sample_prediction_path(paths.best_sample_prediction_path) - item._set_lower_95_path(paths.lower_95_path) - item._set_upper_95_path(paths.upper_95_path) - item._set_lower_68_path(paths.lower_68_path) - item._set_upper_68_path(paths.upper_68_path) - item._set_draws_path(paths.draws_path) - item._set_n_x(n_x) - item._set_n_draws_cached(n_draws_cached) - self.add(item) diff --git a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/factory.py b/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/factory.py deleted file mode 100644 index a88449263..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_predictive_datasets/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-predictive-datasets factory.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class BayesianPredictiveDatasetsFactory(FactoryBase): - """Create Bayesian-predictive-dataset collections by tag.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/analysis/categories/bayesian_result/__init__.py b/src/easydiffraction/analysis/categories/bayesian_result/__init__.py deleted file mode 100644 index eab2a07f3..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_result/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_result.default import BayesianResult -from easydiffraction.analysis.categories.bayesian_result.factory import BayesianResultFactory diff --git a/src/easydiffraction/analysis/categories/bayesian_result/default.py b/src/easydiffraction/analysis/categories/bayesian_result/default.py deleted file mode 100644 index dd4210711..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_result/default.py +++ /dev/null @@ -1,215 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Bayesian fit-result metadata category.""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.bayesian_result.factory import BayesianResultFactory -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import BoolDescriptor -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -@BayesianResultFactory.register -class BayesianResult(CategoryItem): - """Persisted Bayesian fit-result metadata.""" - - _category_code = 'bayesian_result' - - type_info = TypeInfo( - tag='default', - description='Persisted Bayesian fit-result metadata', - ) - - def __init__(self) -> None: - super().__init__() - self._sampler_name = StringDescriptor( - name='sampler_name', - description='Name of the persisted Bayesian sampler.', - value_spec=AttributeSpec(default='dream'), - cif_handler=CifHandler(names=['_bayesian_result.sampler_name']), - ) - self._point_estimate_name = StringDescriptor( - name='point_estimate_name', - description='Committed sampled point estimate name.', - value_spec=AttributeSpec(default='best_sample'), - cif_handler=CifHandler(names=['_bayesian_result.point_estimate_name']), - ) - self._success = BoolDescriptor( - name='success', - description='Whether the persisted Bayesian fit produced usable results.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.success']), - ) - self._sampler_completed = BoolDescriptor( - name='sampler_completed', - description='Whether the sampler completed and returned posterior data.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.sampler_completed']), - ) - self._best_log_posterior = NumericDescriptor( - name='best_log_posterior', - description='Best log-posterior value reported by the sampler.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_result.best_log_posterior']), - ) - self._credible_interval_inner = NumericDescriptor( - name='credible_interval_inner', - description='Inner credible-interval level used in summaries.', - value_spec=AttributeSpec(default=0.68), - cif_handler=CifHandler(names=['_bayesian_result.credible_interval_inner']), - ) - self._credible_interval_outer = NumericDescriptor( - name='credible_interval_outer', - description='Outer credible-interval level used in summaries.', - value_spec=AttributeSpec(default=0.95), - cif_handler=CifHandler(names=['_bayesian_result.credible_interval_outer']), - ) - self._has_posterior_samples = BoolDescriptor( - name='has_posterior_samples', - description='Whether posterior samples were persisted.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.has_posterior_samples']), - ) - self._has_distribution_cache = BoolDescriptor( - name='has_distribution_cache', - description='Whether distribution-cache manifests were persisted.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.has_distribution_cache']), - ) - self._has_pair_cache = BoolDescriptor( - name='has_pair_cache', - description='Whether pair-cache manifests were persisted.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.has_pair_cache']), - ) - self._has_posterior_predictive = BoolDescriptor( - name='has_posterior_predictive', - description='Whether posterior predictive manifests were persisted.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_bayesian_result.has_posterior_predictive']), - ) - self._sidecar_file = StringDescriptor( - name='sidecar_file', - description='Relative path to the persisted Bayesian HDF5 sidecar.', - value_spec=AttributeSpec(default='results.h5'), - cif_handler=CifHandler(names=['_bayesian_result.sidecar_file']), - ) - - @property - def sampler_name(self) -> StringDescriptor: - """Name of the persisted Bayesian sampler.""" - return self._sampler_name - - def _set_sampler_name(self, value: str) -> None: - """Set the sampler name for internal callers.""" - self._sampler_name.value = value - - @property - def point_estimate_name(self) -> StringDescriptor: - """Committed sampled point estimate name.""" - return self._point_estimate_name - - def _set_point_estimate_name(self, value: str) -> None: - """Set the point-estimate name for internal callers.""" - self._point_estimate_name.value = value - - @property - def success(self) -> BoolDescriptor: - """ - Whether the persisted Bayesian fit produced usable results. - """ - return self._success - - def _set_success(self, *, value: bool) -> None: - """Set the success flag for internal callers.""" - self._success.value = value - - @property - def sampler_completed(self) -> BoolDescriptor: - """Whether the sampler completed and returned posterior data.""" - return self._sampler_completed - - def _set_sampler_completed(self, *, value: bool) -> None: - """Set the sampler-completed flag for internal callers.""" - self._sampler_completed.value = value - - @property - def best_log_posterior(self) -> NumericDescriptor: - """Best log-posterior value reported by the sampler.""" - return self._best_log_posterior - - def _set_best_log_posterior(self, value: float | None) -> None: - """Set the best log-posterior for internal callers.""" - self._best_log_posterior.value = value - - @property - def credible_interval_inner(self) -> NumericDescriptor: - """Inner credible-interval level used in summaries.""" - return self._credible_interval_inner - - def _set_credible_interval_inner(self, value: float) -> None: - """ - Set the inner credible-interval level for internal callers. - """ - self._credible_interval_inner.value = value - - @property - def credible_interval_outer(self) -> NumericDescriptor: - """Outer credible-interval level used in summaries.""" - return self._credible_interval_outer - - def _set_credible_interval_outer(self, value: float) -> None: - """ - Set the outer credible-interval level for internal callers. - """ - self._credible_interval_outer.value = value - - @property - def has_posterior_samples(self) -> BoolDescriptor: - """Whether posterior samples were persisted.""" - return self._has_posterior_samples - - def _set_has_posterior_samples(self, *, value: bool) -> None: - """Set the posterior-samples flag for internal callers.""" - self._has_posterior_samples.value = value - - @property - def has_distribution_cache(self) -> BoolDescriptor: - """Whether distribution-cache manifests were persisted.""" - return self._has_distribution_cache - - def _set_has_distribution_cache(self, *, value: bool) -> None: - """Set the distribution-cache flag for internal callers.""" - self._has_distribution_cache.value = value - - @property - def has_pair_cache(self) -> BoolDescriptor: - """Whether pair-cache manifests were persisted.""" - return self._has_pair_cache - - def _set_has_pair_cache(self, *, value: bool) -> None: - """Set the pair-cache flag for internal callers.""" - self._has_pair_cache.value = value - - @property - def has_posterior_predictive(self) -> BoolDescriptor: - """Whether posterior predictive manifests were persisted.""" - return self._has_posterior_predictive - - def _set_has_posterior_predictive(self, *, value: bool) -> None: - """Set the posterior-predictive flag for internal callers.""" - self._has_posterior_predictive.value = value - - @property - def sidecar_file(self) -> StringDescriptor: - """Relative path to the persisted Bayesian HDF5 sidecar.""" - return self._sidecar_file - - def _set_sidecar_file(self, value: str) -> None: - """Set the sidecar-file path for internal callers.""" - self._sidecar_file.value = value diff --git a/src/easydiffraction/analysis/categories/bayesian_sampler/__init__.py b/src/easydiffraction/analysis/categories/bayesian_sampler/__init__.py deleted file mode 100644 index 962e3fbae..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_sampler/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.bayesian_sampler.default import BayesianSampler -from easydiffraction.analysis.categories.bayesian_sampler.factory import BayesianSamplerFactory diff --git a/src/easydiffraction/analysis/categories/bayesian_sampler/default.py b/src/easydiffraction/analysis/categories/bayesian_sampler/default.py deleted file mode 100644 index c75ffa156..000000000 --- a/src/easydiffraction/analysis/categories/bayesian_sampler/default.py +++ /dev/null @@ -1,133 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Resolved Bayesian sampler settings category.""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.bayesian_sampler.factory import BayesianSamplerFactory -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import IntegerDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -@BayesianSamplerFactory.register -class BayesianSampler(CategoryItem): - """Persisted resolved Bayesian sampler settings.""" - - _category_code = 'bayesian_sampler' - - type_info = TypeInfo( - tag='default', - description='Persisted resolved Bayesian sampler settings', - ) - - def __init__(self) -> None: - super().__init__() - self._steps = IntegerDescriptor( - name='steps', - description='Resolved number of sampler steps.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_sampler.steps']), - ) - self._burn = IntegerDescriptor( - name='burn', - description='Resolved burn-in count.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_sampler.burn']), - ) - self._thin = IntegerDescriptor( - name='thin', - description='Resolved thinning interval.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_sampler.thin']), - ) - self._pop = IntegerDescriptor( - name='pop', - description='Resolved population size.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_sampler.pop']), - ) - self._parallel = IntegerDescriptor( - name='parallel', - description='Resolved DREAM worker count; 0 uses all CPUs.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_bayesian_sampler.parallel']), - ) - self._init = StringDescriptor( - name='init', - description='Resolved DREAM initialization mode.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_bayesian_sampler.init']), - ) - self._random_seed = IntegerDescriptor( - name='random_seed', - description='Resolved random seed used by the sampler.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_bayesian_sampler.random_seed']), - ) - - @property - def steps(self) -> IntegerDescriptor: - """Resolved number of sampler steps.""" - return self._steps - - def _set_steps(self, value: int) -> None: - """Set the step count for internal callers.""" - self._steps.value = value - - @property - def burn(self) -> IntegerDescriptor: - """Resolved burn-in count.""" - return self._burn - - def _set_burn(self, value: int) -> None: - """Set the burn-in count for internal callers.""" - self._burn.value = value - - @property - def thin(self) -> IntegerDescriptor: - """Resolved thinning interval.""" - return self._thin - - def _set_thin(self, value: int) -> None: - """Set the thinning interval for internal callers.""" - self._thin.value = value - - @property - def pop(self) -> IntegerDescriptor: - """Resolved population size.""" - return self._pop - - def _set_pop(self, value: int) -> None: - """Set the population size for internal callers.""" - self._pop.value = value - - @property - def parallel(self) -> IntegerDescriptor: - """Resolved DREAM worker count; 0 uses all CPUs.""" - return self._parallel - - def _set_parallel(self, value: int) -> None: - """Set the DREAM worker count for internal callers.""" - self._parallel.value = value - - @property - def init(self) -> StringDescriptor: - """Resolved DREAM initialization mode.""" - return self._init - - def _set_init(self, value: str) -> None: - """Set the initialization mode for internal callers.""" - self._init.value = value - - @property - def random_seed(self) -> IntegerDescriptor: - """Resolved random seed used by the sampler.""" - return self._random_seed - - def _set_random_seed(self, value: int | None) -> None: - """Set the random seed for internal callers.""" - self._random_seed.value = value diff --git a/src/easydiffraction/analysis/categories/deterministic_result/__init__.py b/src/easydiffraction/analysis/categories/deterministic_result/__init__.py deleted file mode 100644 index 18ed7af8f..000000000 --- a/src/easydiffraction/analysis/categories/deterministic_result/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.deterministic_result.default import DeterministicResult -from easydiffraction.analysis.categories.deterministic_result.factory import ( - DeterministicResultFactory, -) diff --git a/src/easydiffraction/analysis/categories/deterministic_result/default.py b/src/easydiffraction/analysis/categories/deterministic_result/default.py deleted file mode 100644 index f66f2018d..000000000 --- a/src/easydiffraction/analysis/categories/deterministic_result/default.py +++ /dev/null @@ -1,187 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Deterministic fit-result metadata category.""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.deterministic_result.factory import ( - DeterministicResultFactory, -) -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import BoolDescriptor -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler - - -@DeterministicResultFactory.register -class DeterministicResult(CategoryItem): - """Persisted deterministic fit-result metadata.""" - - _category_code = 'deterministic_result' - - type_info = TypeInfo( - tag='default', - description='Persisted deterministic fit-result metadata', - ) - - def __init__(self) -> None: - super().__init__() - self._optimizer_name = StringDescriptor( - name='optimizer_name', - description='Name of the persisted deterministic optimizer.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_deterministic_result.optimizer_name']), - ) - self._method_name = StringDescriptor( - name='method_name', - description='Method name of the persisted deterministic optimizer.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_deterministic_result.method_name']), - ) - self._objective_name = StringDescriptor( - name='objective_name', - description='Objective function name for the persisted deterministic fit.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_deterministic_result.objective_name']), - ) - self._objective_value = NumericDescriptor( - name='objective_value', - description='Objective value for the persisted deterministic fit.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_deterministic_result.objective_value']), - ) - self._n_data_points = NumericDescriptor( - name='n_data_points', - description='Number of data points used in the persisted deterministic fit.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_deterministic_result.n_data_points']), - ) - self._n_parameters = NumericDescriptor( - name='n_parameters', - description='Number of parameters considered in the persisted deterministic fit.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_deterministic_result.n_parameters']), - ) - self._n_free_parameters = NumericDescriptor( - name='n_free_parameters', - description='Number of free parameters in the persisted deterministic fit.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_deterministic_result.n_free_parameters']), - ) - self._degrees_of_freedom = NumericDescriptor( - name='degrees_of_freedom', - description='Degrees of freedom for the persisted deterministic fit.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_deterministic_result.degrees_of_freedom']), - ) - self._covariance_available = BoolDescriptor( - name='covariance_available', - description='Whether covariance was available for the persisted deterministic fit.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_deterministic_result.covariance_available']), - ) - self._correlation_available = BoolDescriptor( - name='correlation_available', - description='Whether correlations were available for the persisted deterministic fit.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_deterministic_result.correlation_available']), - ) - - @property - def optimizer_name(self) -> StringDescriptor: - """Name of the persisted deterministic optimizer.""" - return self._optimizer_name - - def _set_optimizer_name(self, value: str) -> None: - """Set the optimizer name for internal callers.""" - self._optimizer_name.value = value - - @property - def method_name(self) -> StringDescriptor: - """Method name of the persisted deterministic optimizer.""" - return self._method_name - - def _set_method_name(self, value: str) -> None: - """Set the method name for internal callers.""" - self._method_name.value = value - - @property - def objective_name(self) -> StringDescriptor: - """ - Objective function name for the persisted deterministic fit. - """ - return self._objective_name - - def _set_objective_name(self, value: str) -> None: - """Set the objective name for internal callers.""" - self._objective_name.value = value - - @property - def objective_value(self) -> NumericDescriptor: - """Objective value for the persisted deterministic fit.""" - return self._objective_value - - def _set_objective_value(self, value: float | None) -> None: - """Set the objective value for internal callers.""" - self._objective_value.value = value - - @property - def n_data_points(self) -> NumericDescriptor: - """ - Number of data points used in the persisted deterministic fit. - """ - return self._n_data_points - - def _set_n_data_points(self, value: float) -> None: - """Set the data-point count for internal callers.""" - self._n_data_points.value = value - - @property - def n_parameters(self) -> NumericDescriptor: - """Number of parameters considered in the persisted fit.""" - return self._n_parameters - - def _set_n_parameters(self, value: float) -> None: - """Set the parameter count for internal callers.""" - self._n_parameters.value = value - - @property - def n_free_parameters(self) -> NumericDescriptor: - """ - Number of free parameters in the persisted deterministic fit. - """ - return self._n_free_parameters - - def _set_n_free_parameters(self, value: float) -> None: - """Set the free-parameter count for internal callers.""" - self._n_free_parameters.value = value - - @property - def degrees_of_freedom(self) -> NumericDescriptor: - """Degrees of freedom for the persisted deterministic fit.""" - return self._degrees_of_freedom - - def _set_degrees_of_freedom(self, value: float) -> None: - """Set the degrees of freedom for internal callers.""" - self._degrees_of_freedom.value = value - - @property - def covariance_available(self) -> BoolDescriptor: - """Whether covariance was available for the persisted fit.""" - return self._covariance_available - - def _set_covariance_available(self, *, value: bool) -> None: - """Set the covariance-available flag for internal callers.""" - self._covariance_available.value = value - - @property - def correlation_available(self) -> BoolDescriptor: - """Whether correlations were available for the persisted fit.""" - return self._correlation_available - - def _set_correlation_available(self, *, value: bool) -> None: - """Set the correlation-available flag for internal callers.""" - self._correlation_available.value = value diff --git a/src/easydiffraction/analysis/categories/deterministic_result/factory.py b/src/easydiffraction/analysis/categories/deterministic_result/factory.py deleted file mode 100644 index 1ee96acf9..000000000 --- a/src/easydiffraction/analysis/categories/deterministic_result/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Deterministic-result factory.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class DeterministicResultFactory(FactoryBase): - """Create deterministic-result categories by tag.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/analysis/categories/fit_parameters/default.py b/src/easydiffraction/analysis/categories/fit_parameters/default.py index a5e1f94f5..2eed6ee1f 100644 --- a/src/easydiffraction/analysis/categories/fit_parameters/default.py +++ b/src/easydiffraction/analysis/categories/fit_parameters/default.py @@ -10,6 +10,7 @@ from easydiffraction.core.category import CategoryCollection from easydiffraction.core.category import CategoryItem from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.posterior import PosteriorParameterSummary from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.validation import RegexValidator from easydiffraction.core.variable import NumericDescriptor @@ -64,6 +65,60 @@ def __init__(self) -> None: value_spec=AttributeSpec(default=None, allow_none=True), cif_handler=CifHandler(names=['_fit_parameter.start_uncertainty']), ) + self._posterior_best_sample_value = NumericDescriptor( + name='posterior_best_sample_value', + description='Highest-posterior sampled parameter value.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_best_sample_value']), + ) + self._posterior_median = NumericDescriptor( + name='posterior_median', + description='Posterior median value.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_median']), + ) + self._posterior_uncertainty = NumericDescriptor( + name='posterior_uncertainty', + description='Posterior standard deviation.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_uncertainty']), + ) + self._posterior_interval_68_low = NumericDescriptor( + name='posterior_interval_68_low', + description='Lower bound of the 68% credible interval.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_interval_68_low']), + ) + self._posterior_interval_68_high = NumericDescriptor( + name='posterior_interval_68_high', + description='Upper bound of the 68% credible interval.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_interval_68_high']), + ) + self._posterior_interval_95_low = NumericDescriptor( + name='posterior_interval_95_low', + description='Lower bound of the 95% credible interval.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_interval_95_low']), + ) + self._posterior_interval_95_high = NumericDescriptor( + name='posterior_interval_95_high', + description='Upper bound of the 95% credible interval.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_interval_95_high']), + ) + self._posterior_gelman_rubin = NumericDescriptor( + name='posterior_gelman_rubin', + description='Rank-normalized split-R-hat when available.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_gelman_rubin']), + ) + self._posterior_effective_sample_size_bulk = NumericDescriptor( + name='posterior_effective_sample_size_bulk', + description='Bulk effective sample size when available.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_parameter.posterior_effective_sample_size_bulk']), + ) @property def param_unique_name(self) -> StringDescriptor: @@ -126,6 +181,144 @@ def _set_start_uncertainty(self, value: float | None) -> None: """Set the pre-fit uncertainty snapshot for internal callers.""" self._start_uncertainty.value = value + @property + def posterior_best_sample_value(self) -> NumericDescriptor: + """Highest-posterior sampled parameter value.""" + return self._posterior_best_sample_value + + def _set_posterior_best_sample_value(self, value: float | None) -> None: + """Set the posterior best sample for internal callers.""" + self._posterior_best_sample_value.value = value + + @property + def posterior_median(self) -> NumericDescriptor: + """Posterior median value.""" + return self._posterior_median + + def _set_posterior_median(self, value: float | None) -> None: + """Set the posterior median for internal callers.""" + self._posterior_median.value = value + + @property + def posterior_uncertainty(self) -> NumericDescriptor: + """Posterior standard deviation.""" + return self._posterior_uncertainty + + def _set_posterior_uncertainty(self, value: float | None) -> None: + """Set the posterior uncertainty for internal callers.""" + self._posterior_uncertainty.value = value + + @property + def posterior_interval_68_low(self) -> NumericDescriptor: + """Lower bound of the 68% credible interval.""" + return self._posterior_interval_68_low + + def _set_posterior_interval_68_low(self, value: float | None) -> None: + """Set the lower 68% interval bound for internal callers.""" + self._posterior_interval_68_low.value = value + + @property + def posterior_interval_68_high(self) -> NumericDescriptor: + """Upper bound of the 68% credible interval.""" + return self._posterior_interval_68_high + + def _set_posterior_interval_68_high(self, value: float | None) -> None: + """Set the upper 68% interval bound for internal callers.""" + self._posterior_interval_68_high.value = value + + @property + def posterior_interval_95_low(self) -> NumericDescriptor: + """Lower bound of the 95% credible interval.""" + return self._posterior_interval_95_low + + def _set_posterior_interval_95_low(self, value: float | None) -> None: + """Set the lower 95% interval bound for internal callers.""" + self._posterior_interval_95_low.value = value + + @property + def posterior_interval_95_high(self) -> NumericDescriptor: + """Upper bound of the 95% credible interval.""" + return self._posterior_interval_95_high + + def _set_posterior_interval_95_high(self, value: float | None) -> None: + """Set the upper 95% interval bound for internal callers.""" + self._posterior_interval_95_high.value = value + + @property + def posterior_gelman_rubin(self) -> NumericDescriptor: + """Rank-normalized split-R-hat when available.""" + return self._posterior_gelman_rubin + + def _set_posterior_gelman_rubin(self, value: float | None) -> None: + """Set the posterior R-hat for internal callers.""" + self._posterior_gelman_rubin.value = value + + @property + def posterior_effective_sample_size_bulk(self) -> NumericDescriptor: + """Bulk effective sample size when available.""" + return self._posterior_effective_sample_size_bulk + + def _set_posterior_effective_sample_size_bulk(self, value: float | None) -> None: + """Set the posterior bulk ESS for internal callers.""" + self._posterior_effective_sample_size_bulk.value = value + + def _set_posterior_summary(self, summary: PosteriorParameterSummary) -> None: + """Set posterior summary fields for internal callers.""" + self._set_posterior_best_sample_value(summary.best_sample_value) + self._set_posterior_median(summary.median) + self._set_posterior_uncertainty(summary.standard_deviation) + self._set_posterior_interval_68_low(summary.interval_68[0]) + self._set_posterior_interval_68_high(summary.interval_68[1]) + self._set_posterior_interval_95_low(summary.interval_95[0]) + self._set_posterior_interval_95_high(summary.interval_95[1]) + self._set_posterior_gelman_rubin(summary.r_hat) + self._set_posterior_effective_sample_size_bulk(summary.ess_bulk) + + def has_posterior_summary(self) -> bool: + """Return whether any posterior summary field is populated.""" + return any( + value is not None + for value in ( + self.posterior_best_sample_value.value, + self.posterior_median.value, + self.posterior_uncertainty.value, + self.posterior_interval_68_low.value, + self.posterior_interval_68_high.value, + self.posterior_interval_95_low.value, + self.posterior_interval_95_high.value, + self.posterior_gelman_rubin.value, + self.posterior_effective_sample_size_bulk.value, + ) + ) + + @staticmethod + def _posterior_float(value: float | None) -> float: + """Return a posterior value or NaN for incomplete rows.""" + return np.nan if value is None else float(value) + + def posterior_summary(self, *, display_name: str) -> PosteriorParameterSummary | None: + """Return this row as a posterior summary, if populated.""" + if not self.has_posterior_summary(): + return None + + return PosteriorParameterSummary( + unique_name=self.param_unique_name.value, + display_name=display_name, + best_sample_value=self._posterior_float(self.posterior_best_sample_value.value), + median=self._posterior_float(self.posterior_median.value), + standard_deviation=self._posterior_float(self.posterior_uncertainty.value), + interval_68=( + self._posterior_float(self.posterior_interval_68_low.value), + self._posterior_float(self.posterior_interval_68_high.value), + ), + interval_95=( + self._posterior_float(self.posterior_interval_95_low.value), + self._posterior_float(self.posterior_interval_95_high.value), + ), + ess_bulk=self.posterior_effective_sample_size_bulk.value, + r_hat=self.posterior_gelman_rubin.value, + ) + @FitParametersFactory.register class FitParameters(CategoryCollection): @@ -175,3 +368,8 @@ def create( item._set_start_value(start_value) item._set_start_uncertainty(start_uncertainty) self.add(item) + + def set_posterior_summary(self, summary: PosteriorParameterSummary) -> None: + """Attach a posterior summary to an existing row.""" + item = self[summary.unique_name] + item._set_posterior_summary(summary) diff --git a/src/easydiffraction/analysis/categories/fitting/__init__.py b/src/easydiffraction/analysis/categories/fitting/__init__.py deleted file mode 100644 index 07fba76f3..000000000 --- a/src/easydiffraction/analysis/categories/fitting/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.analysis.categories.fitting.default import Fitting -from easydiffraction.analysis.categories.fitting.factory import FittingFactory diff --git a/src/easydiffraction/analysis/categories/fitting/default.py b/src/easydiffraction/analysis/categories/fitting/default.py deleted file mode 100644 index 141e22365..000000000 --- a/src/easydiffraction/analysis/categories/fitting/default.py +++ /dev/null @@ -1,125 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -""" -Fitting category item. - -Stores the active minimizer as a CIF-serializable descriptor. -""" - -from __future__ import annotations - -from easydiffraction.analysis.categories.fitting.factory import FittingFactory -from easydiffraction.analysis.fitting import Fitter -from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum -from easydiffraction.analysis.minimizers.factory import MinimizerFactory -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import MembershipValidator -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler -from easydiffraction.utils.logging import console -from easydiffraction.utils.logging import log -from easydiffraction.utils.utils import render_table - - -@FittingFactory.register -class Fitting(CategoryItem): - """ - Analysis fitting configuration category. - - Holds the active minimizer backend tag. - """ - - _category_code = 'fitting' - - type_info = TypeInfo( - tag='default', - description='Fitting configuration category', - ) - - def __init__(self) -> None: - super().__init__() - - self._minimizer_type: StringDescriptor = StringDescriptor( - name='minimizer_type', - description='Fitting minimizer backend type', - value_spec=AttributeSpec( - default=MinimizerTypeEnum.default().value, - validator=MembershipValidator( - allowed=[member.value for member in MinimizerTypeEnum] - ), - ), - cif_handler=CifHandler(names=['_fitting.minimizer_type']), - ) - - @property - def minimizer_type(self) -> StringDescriptor: - """Fitting minimizer backend type.""" - return self._minimizer_type - - @minimizer_type.setter - def minimizer_type(self, value: str) -> None: - new_fitter = Fitter(value) - self._minimizer_type.value = value - parent = getattr(self, '_parent', None) - if parent is None: - return - parent.fitter = new_fitter - console.paragraph('Current minimizer changed to') - console.print(self._minimizer_type.value) - - @property - def minimizer(self) -> object | None: - """Live minimizer backend instance, if attached to Analysis.""" - parent = getattr(self, '_parent', None) - if parent is None or getattr(parent, 'fitter', None) is None: - return None - return parent.fitter.minimizer - - def show_minimizer_types(self) -> None: - """Print supported minimizers and mark the current selection.""" - current = self.minimizer_type.value - supported = MinimizerFactory.supported_tags() - all_classes = MinimizerFactory._supported_map() - columns_data = [ - ['*' if tag == current else '', tag, cls.type_info.description] - for tag, cls in all_classes.items() - if tag in supported - ] - console.paragraph('Minimizer types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) - - @staticmethod - def show_available_minimizers() -> None: - """Print available minimizer drivers on this system.""" - MinimizerFactory.show_supported() - - @property - def as_cif(self) -> str: - """Return CIF representation of this fitting category.""" - return super().as_cif - - def from_cif(self, block: object, idx: int = 0) -> None: - """ - Populate this fitting configuration from a CIF block. - - Parameters - ---------- - block : object - Parsed CIF block. - idx : int, default=0 - Row index for loop-like callers; unused for this category. - """ - super().from_cif(block, idx) - parent = getattr(self, '_parent', None) - if parent is None: - return - try: - parent.fitter = Fitter(self._minimizer_type.value) - except ValueError as error: - log.warning(str(error)) diff --git a/src/easydiffraction/analysis/categories/fitting/factory.py b/src/easydiffraction/analysis/categories/fitting/factory.py deleted file mode 100644 index b88bf9178..000000000 --- a/src/easydiffraction/analysis/categories/fitting/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Fitting factory - delegates entirely to ``FactoryBase``.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class FittingFactory(FactoryBase): - """Create fitting category items by tag.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/analysis/categories/fitting_mode/__init__.py b/src/easydiffraction/analysis/categories/fitting_mode/__init__.py new file mode 100644 index 000000000..e4dd5b1b0 --- /dev/null +++ b/src/easydiffraction/analysis/categories/fitting_mode/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Analysis fitting-mode category exports.""" + +from __future__ import annotations + +from easydiffraction.analysis.categories.fitting_mode.default import FittingMode +from easydiffraction.analysis.categories.fitting_mode.factory import FittingModeFactory diff --git a/src/easydiffraction/analysis/categories/fitting_mode/default.py b/src/easydiffraction/analysis/categories/fitting_mode/default.py new file mode 100644 index 000000000..b3080a1e5 --- /dev/null +++ b/src/easydiffraction/analysis/categories/fitting_mode/default.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Analysis fitting-mode category.""" + +from __future__ import annotations + +from easydiffraction.analysis.categories.fitting_mode.factory import FittingModeFactory +from easydiffraction.analysis.enums import FitModeEnum +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +@FittingModeFactory.register +class FittingMode(CategoryItem, SwitchableCategoryBase): + """Fitting-mode selector for an analysis.""" + + _category_code = 'fitting_mode' + _owner_attr_name = 'fitting_mode' + _swap_method_name = '_swap_fitting_mode' + + type_info = TypeInfo( + tag='default', + description='Analysis fitting-mode category', + ) + + def __init__(self) -> None: + super().__init__() + + self._type = StringDescriptor( + name='type', + description='Active fitting mode', + value_spec=AttributeSpec( + default=FitModeEnum.default().value, + validator=MembershipValidator( + allowed=[mode.value for mode in FitModeEnum], + ), + ), + cif_handler=CifHandler(names=['_fitting_mode.type']), + ) + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return supported fitting modes.""" + del filters + return [(mode.value, mode.description()) for mode in FitModeEnum] diff --git a/src/easydiffraction/analysis/categories/bayesian_pair_caches/factory.py b/src/easydiffraction/analysis/categories/fitting_mode/factory.py similarity index 68% rename from src/easydiffraction/analysis/categories/bayesian_pair_caches/factory.py rename to src/easydiffraction/analysis/categories/fitting_mode/factory.py index 5c6df89b1..03263c3b7 100644 --- a/src/easydiffraction/analysis/categories/bayesian_pair_caches/factory.py +++ b/src/easydiffraction/analysis/categories/fitting_mode/factory.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-pair-caches factory.""" +"""Factory for analysis fitting-mode categories.""" from __future__ import annotations @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class BayesianPairCachesFactory(FactoryBase): - """Create Bayesian-pair-cache collections by tag.""" +class FittingModeFactory(FactoryBase): + """Create analysis fitting-mode category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/analysis/categories/minimizer/__init__.py b/src/easydiffraction/analysis/categories/minimizer/__init__.py new file mode 100644 index 000000000..17ab65c81 --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/__init__.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Minimizer category implementations.""" + +from easydiffraction.analysis.categories.minimizer.bayesian_base import BayesianMinimizerBase +from easydiffraction.analysis.categories.minimizer.bumps import BumpsMinimizer +from easydiffraction.analysis.categories.minimizer.bumps_amoeba import BumpsAmoebaMinimizer +from easydiffraction.analysis.categories.minimizer.bumps_de import BumpsDeMinimizer +from easydiffraction.analysis.categories.minimizer.bumps_dream import BumpsDreamMinimizer +from easydiffraction.analysis.categories.minimizer.bumps_lm import BumpsLmMinimizer +from easydiffraction.analysis.categories.minimizer.dfols import DfolsMinimizer +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lmfit import LmfitMinimizer +from easydiffraction.analysis.categories.minimizer.lmfit_least_squares import ( + LmfitLeastSquaresMinimizer, +) +from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import LmfitLeastsqMinimizer +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase diff --git a/src/easydiffraction/analysis/categories/minimizer/base.py b/src/easydiffraction/analysis/categories/minimizer/base.py new file mode 100644 index 000000000..1e0a6c672 --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/base.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Base class for persisted minimizer category items.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import GenericDescriptorBase +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): + """Base class for persisted minimizer settings and results.""" + + _category_code = 'minimizer' + _owner_attr_name = 'minimizer' + _swap_method_name = '_swap_minimizer' + _native_key_map: ClassVar[dict[str, str]] = {} + _setting_descriptor_names: ClassVar[tuple[str, ...]] = () + _result_descriptor_names: ClassVar[tuple[str, ...]] = () + + def __init__(self) -> None: + super().__init__() + self._type = StringDescriptor( + name='type', + description='Minimizer category type.', + value_spec=AttributeSpec( + default=str(self.type_info.tag), + validator=MembershipValidator( + allowed=[member.value for member in MinimizerTypeEnum], + ), + ), + cif_handler=CifHandler(names=['_minimizer.type']), + ) + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return minimizer types supported by the factory.""" + del filters + from easydiffraction.analysis.categories.minimizer.factory import ( # noqa: PLC0415 + MinimizerCategoryFactory, + ) + + return [ + (str(tag), klass.type_info.description) + for tag, klass in MinimizerCategoryFactory._supported_map().items() + ] + + def _descriptor_values(self, names: tuple[str, ...]) -> dict[str, object]: + """Return descriptor values for the named public attributes.""" + values: dict[str, object] = {} + for name in names: + descriptor = getattr(self, name) + if isinstance(descriptor, GenericDescriptorBase): + values[name] = descriptor.value + else: + values[name] = descriptor + return values + + def _reset_result_descriptors(self) -> None: + """Reset fit-result descriptors to their declared defaults.""" + for name in self._result_descriptor_names: + descriptor = getattr(self, name) + if isinstance(descriptor, GenericDescriptorBase): + descriptor.value = descriptor._value_spec.default_value() + + def _native_kwargs(self) -> dict[str, object]: + """ + Return backend keyword arguments keyed by native names. + + Returns + ------- + dict[str, object] + Descriptor values mapped from public minimizer attributes to + backend-specific keyword names. + """ + kwargs: dict[str, object] = {} + for attr_name, native_key in self._native_key_map.items(): + attr = getattr(self, attr_name) + if isinstance(attr, GenericDescriptorBase): + kwargs[native_key] = attr.value + else: + kwargs[native_key] = attr + return kwargs diff --git a/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py new file mode 100644 index 000000000..7e999b859 --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py @@ -0,0 +1,404 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Behavior helpers for Bayesian minimizer categories.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase +from easydiffraction.analysis.minimizers.enums import InitializationMethodEnum +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.validation import RangeValidator +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import IntegerDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +class BayesianMinimizerBase(MinimizerCategoryBase): + """Shared behavior for Bayesian minimizer categories.""" + + _expected_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'sampling_steps', + 'burn_in_steps', + 'thinning_interval', + 'population_size', + 'parallel_workers', + 'initialization_method', + 'random_seed', + 'runtime_seconds', + 'point_estimate_name', + 'sampler_completed', + 'credible_interval_inner', + 'credible_interval_outer', + 'acceptance_rate_mean', + 'gelman_rubin_max', + 'effective_sample_size_min', + 'best_log_posterior', + ) + _native_key_map: ClassVar[dict[str, str]] = { + 'sampling_steps': 'steps', + 'burn_in_steps': 'burn', + 'thinning_interval': 'thin', + 'population_size': 'pop', + 'parallel_workers': 'parallel', + 'initialization_method': 'init', + 'random_seed': 'random_seed', + } + _setting_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'sampling_steps', + 'burn_in_steps', + 'thinning_interval', + 'population_size', + 'parallel_workers', + 'initialization_method', + 'random_seed', + ) + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'runtime_seconds', + 'point_estimate_name', + 'sampler_completed', + 'credible_interval_inner', + 'credible_interval_outer', + 'acceptance_rate_mean', + 'gelman_rubin_max', + 'effective_sample_size_min', + 'best_log_posterior', + ) + _supported_initialization_methods: ClassVar[tuple[InitializationMethodEnum, ...]] = ( + InitializationMethodEnum.LATIN_HYPERCUBE, + ) + _native_initialization_methods: ClassVar[dict[InitializationMethodEnum, str]] = { + InitializationMethodEnum.LATIN_HYPERCUBE: 'lhs', + } + + def _native_kwargs(self) -> dict[str, object]: + """Return backend keyword arguments keyed by native names.""" + kwargs = super()._native_kwargs() + if 'init' not in kwargs: + return kwargs + method = InitializationMethodEnum(str(kwargs['init'])) + kwargs['init'] = self._native_initialization_methods[method] + return kwargs + + @staticmethod + def _sampling_steps_descriptor(default: int) -> IntegerDescriptor: + """Create a sampling-steps descriptor.""" + return IntegerDescriptor( + name='sampling_steps', + description='Total sampler iterations per chain.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=1)), + cif_handler=CifHandler(names=['_minimizer.sampling_steps']), + ) + + @staticmethod + def _burn_in_steps_descriptor(default: int) -> IntegerDescriptor: + """Create a burn-in descriptor.""" + return IntegerDescriptor( + name='burn_in_steps', + description='Sampler iterations discarded as warm-up.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=0)), + cif_handler=CifHandler(names=['_minimizer.burn_in_steps']), + ) + + @staticmethod + def _thinning_interval_descriptor(default: int) -> IntegerDescriptor: + """Create a thinning-interval descriptor.""" + return IntegerDescriptor( + name='thinning_interval', + description='Sampler thinning interval.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=1)), + cif_handler=CifHandler(names=['_minimizer.thinning_interval']), + ) + + @staticmethod + def _population_size_descriptor(default: int) -> IntegerDescriptor: + """Create a population-size descriptor.""" + return IntegerDescriptor( + name='population_size', + description='Number of chains or walkers.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=1)), + cif_handler=CifHandler(names=['_minimizer.population_size']), + ) + + @staticmethod + def _parallel_workers_descriptor(default: int) -> IntegerDescriptor: + """Create a parallel-workers descriptor.""" + return IntegerDescriptor( + name='parallel_workers', + description='Worker count; 0 uses all available CPUs.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=0)), + cif_handler=CifHandler(names=['_minimizer.parallel_workers']), + ) + + @classmethod + def _initialization_method_descriptor(cls) -> StringDescriptor: + """Create an initialization-method descriptor.""" + allowed = [member.value for member in cls._supported_initialization_methods] + return StringDescriptor( + name='initialization_method', + description='Sampler initialization method.', + value_spec=AttributeSpec( + default=InitializationMethodEnum.LATIN_HYPERCUBE.value, + validator=MembershipValidator(allowed=allowed), + ), + cif_handler=CifHandler(names=['_minimizer.initialization_method']), + ) + + @staticmethod + def _random_seed_descriptor() -> IntegerDescriptor: + """Create a random-seed descriptor.""" + return IntegerDescriptor( + name='random_seed', + description='Random seed; None uses a system-derived seed.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.random_seed']), + ) + + @staticmethod + def _runtime_seconds_descriptor() -> NumericDescriptor: + """Create a runtime-seconds descriptor.""" + return NumericDescriptor( + name='runtime_seconds', + description='Wall time of the fit in seconds.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.runtime_seconds']), + ) + + @staticmethod + def _point_estimate_name_descriptor() -> StringDescriptor: + """Create a point-estimate-name descriptor.""" + return StringDescriptor( + name='point_estimate_name', + description='Committed sampled point estimate name.', + value_spec=AttributeSpec(default='best_sample'), + cif_handler=CifHandler(names=['_minimizer.point_estimate_name']), + ) + + @staticmethod + def _sampler_completed_descriptor() -> BoolDescriptor: + """Create a sampler-completed descriptor.""" + return BoolDescriptor( + name='sampler_completed', + description='Whether the sampler completed and returned posterior data.', + value_spec=AttributeSpec(default=False), + cif_handler=CifHandler(names=['_minimizer.sampler_completed']), + ) + + @staticmethod + def _credible_interval_inner_descriptor() -> NumericDescriptor: + """Create an inner credible-interval descriptor.""" + return NumericDescriptor( + name='credible_interval_inner', + description='Inner credible-interval level used in summaries.', + value_spec=AttributeSpec(default=0.68), + cif_handler=CifHandler(names=['_minimizer.credible_interval_inner']), + ) + + @staticmethod + def _credible_interval_outer_descriptor() -> NumericDescriptor: + """Create an outer credible-interval descriptor.""" + return NumericDescriptor( + name='credible_interval_outer', + description='Outer credible-interval level used in summaries.', + value_spec=AttributeSpec(default=0.95), + cif_handler=CifHandler(names=['_minimizer.credible_interval_outer']), + ) + + @staticmethod + def _acceptance_rate_mean_descriptor() -> NumericDescriptor: + """Create an acceptance-rate descriptor.""" + return NumericDescriptor( + name='acceptance_rate_mean', + description='Mean sampler acceptance rate.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.acceptance_rate_mean']), + ) + + @staticmethod + def _gelman_rubin_max_descriptor() -> NumericDescriptor: + """Create a Gelman-Rubin descriptor.""" + return NumericDescriptor( + name='gelman_rubin_max', + description='Maximum rank-normalized split R-hat.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.gelman_rubin_max']), + ) + + @staticmethod + def _effective_sample_size_min_descriptor() -> NumericDescriptor: + """Create an effective-sample-size descriptor.""" + return NumericDescriptor( + name='effective_sample_size_min', + description='Minimum bulk effective sample size.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.effective_sample_size_min']), + ) + + @staticmethod + def _best_log_posterior_descriptor() -> NumericDescriptor: + """Create a best-log-posterior descriptor.""" + return NumericDescriptor( + name='best_log_posterior', + description='Best log-posterior value found.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_minimizer.best_log_posterior']), + ) + + @property + def sampling_steps(self) -> IntegerDescriptor: + """Total sampler iterations per chain.""" + return self._sampling_steps + + @sampling_steps.setter + def sampling_steps(self, value: int) -> None: + self._sampling_steps.value = value + + @property + def burn_in_steps(self) -> IntegerDescriptor: + """Sampler iterations discarded as warm-up.""" + return self._burn_in_steps + + @burn_in_steps.setter + def burn_in_steps(self, value: int) -> None: + self._burn_in_steps.value = value + + @property + def thinning_interval(self) -> IntegerDescriptor: + """Sampler thinning interval.""" + return self._thinning_interval + + @thinning_interval.setter + def thinning_interval(self, value: int) -> None: + self._thinning_interval.value = value + + @property + def population_size(self) -> IntegerDescriptor: + """Number of chains or walkers.""" + return self._population_size + + @population_size.setter + def population_size(self, value: int) -> None: + self._population_size.value = value + + @property + def parallel_workers(self) -> IntegerDescriptor: + """Worker count; 0 uses all available CPUs.""" + return self._parallel_workers + + @parallel_workers.setter + def parallel_workers(self, value: int) -> None: + self._parallel_workers.value = value + + @property + def initialization_method(self) -> StringDescriptor: + """Sampler initialization method.""" + return self._initialization_method + + @initialization_method.setter + def initialization_method(self, value: InitializationMethodEnum | str) -> None: + method = InitializationMethodEnum(value) + if method not in self._supported_initialization_methods: + supported = ', '.join(item.value for item in self._supported_initialization_methods) + msg = f"Initialization method '{method.value}' is unsupported; use: {supported}." + raise ValueError(msg) + self._initialization_method.value = method.value + + @property + def random_seed(self) -> IntegerDescriptor: + """Random seed; None uses a system-derived seed.""" + return self._random_seed + + @random_seed.setter + def random_seed(self, value: int | None) -> None: + self._random_seed.value = value + + @property + def runtime_seconds(self) -> NumericDescriptor: + """Wall time of the fit in seconds.""" + return self._runtime_seconds + + def _set_runtime_seconds(self, value: float | None) -> None: + """Set the fit runtime for internal callers.""" + self._runtime_seconds.value = value + + @property + def point_estimate_name(self) -> StringDescriptor: + """Committed sampled point estimate name.""" + return self._point_estimate_name + + def _set_point_estimate_name(self, value: str) -> None: + """Set the point-estimate name for internal callers.""" + self._point_estimate_name.value = value + + @property + def sampler_completed(self) -> BoolDescriptor: + """Whether the sampler completed and returned posterior data.""" + return self._sampler_completed + + def _set_sampler_completed(self, *, value: bool) -> None: + """Set the sampler-completed flag for internal callers.""" + self._sampler_completed.value = value + + @property + def credible_interval_inner(self) -> NumericDescriptor: + """Inner credible-interval level used in summaries.""" + return self._credible_interval_inner + + def _set_credible_interval_inner(self, value: float) -> None: + """ + Set the inner credible-interval level for internal callers. + """ + self._credible_interval_inner.value = value + + @property + def credible_interval_outer(self) -> NumericDescriptor: + """Outer credible-interval level used in summaries.""" + return self._credible_interval_outer + + def _set_credible_interval_outer(self, value: float) -> None: + """ + Set the outer credible-interval level for internal callers. + """ + self._credible_interval_outer.value = value + + @property + def acceptance_rate_mean(self) -> NumericDescriptor: + """Mean sampler acceptance rate.""" + return self._acceptance_rate_mean + + def _set_acceptance_rate_mean(self, value: float | None) -> None: + """Set the acceptance-rate mean for internal callers.""" + self._acceptance_rate_mean.value = value + + @property + def gelman_rubin_max(self) -> NumericDescriptor: + """Maximum rank-normalized split R-hat.""" + return self._gelman_rubin_max + + def _set_gelman_rubin_max(self, value: float | None) -> None: + """Set the maximum R-hat for internal callers.""" + self._gelman_rubin_max.value = value + + @property + def effective_sample_size_min(self) -> NumericDescriptor: + """Minimum bulk effective sample size.""" + return self._effective_sample_size_min + + def _set_effective_sample_size_min(self, value: float | None) -> None: + """ + Set the minimum effective sample size for internal callers. + """ + self._effective_sample_size_min.value = value + + @property + def best_log_posterior(self) -> NumericDescriptor: + """Best log-posterior value found.""" + return self._best_log_posterior + + def _set_best_log_posterior(self, value: float | None) -> None: + """Set the best log-posterior for internal callers.""" + self._best_log_posterior.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps.py b/src/easydiffraction/analysis/categories/minimizer/bumps.py new file mode 100644 index 000000000..198c3155e --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bumps.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the default BUMPS minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class BumpsMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the default BUMPS minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'bumps', + 'method_name': 'lm', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.BUMPS, + description='BUMPS library using the default Levenberg-Marquardt method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_amoeba.py b/src/easydiffraction/analysis/categories/minimizer/bumps_amoeba.py new file mode 100644 index 000000000..b27c8f7ea --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_amoeba.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the BUMPS amoeba minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class BumpsAmoebaMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the BUMPS amoeba minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'bumps (amoeba)', + 'method_name': 'amoeba', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.BUMPS_AMOEBA, + description='BUMPS library with Nelder-Mead simplex method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_de.py b/src/easydiffraction/analysis/categories/minimizer/bumps_de.py new file mode 100644 index 000000000..097f6bfea --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_de.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the BUMPS de minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class BumpsDeMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the BUMPS de minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'bumps (de)', + 'method_name': 'de', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.BUMPS_DE, + description='BUMPS library with differential evolution method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py new file mode 100644 index 000000000..2fb8caebd --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the BUMPS DREAM minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.bayesian_base import BayesianMinimizerBase +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + +DEFAULT_SAMPLING_STEPS = 3000 +DEFAULT_BURN_IN_STEPS = 600 +DEFAULT_THINNING_INTERVAL = 1 +DEFAULT_POPULATION_SIZE = 4 +DEFAULT_PARALLEL_WORKERS = 0 + + +@MinimizerCategoryFactory.register +class BumpsDreamMinimizer(BayesianMinimizerBase): + """Persisted settings and results for the BUMPS DREAM minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'bumps (dream)', + 'method_name': 'dream', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.BUMPS_DREAM, + description='BUMPS library with DREAM Bayesian sampling', + ) + + def __init__(self) -> None: + super().__init__() + self._sampling_steps = self._sampling_steps_descriptor(DEFAULT_SAMPLING_STEPS) + self._burn_in_steps = self._burn_in_steps_descriptor(DEFAULT_BURN_IN_STEPS) + self._thinning_interval = self._thinning_interval_descriptor(DEFAULT_THINNING_INTERVAL) + self._population_size = self._population_size_descriptor(DEFAULT_POPULATION_SIZE) + self._parallel_workers = self._parallel_workers_descriptor(DEFAULT_PARALLEL_WORKERS) + self._initialization_method = self._initialization_method_descriptor() + self._random_seed = self._random_seed_descriptor() + self._runtime_seconds = self._runtime_seconds_descriptor() + self._point_estimate_name = self._point_estimate_name_descriptor() + self._sampler_completed = self._sampler_completed_descriptor() + self._credible_interval_inner = self._credible_interval_inner_descriptor() + self._credible_interval_outer = self._credible_interval_outer_descriptor() + self._acceptance_rate_mean = self._acceptance_rate_mean_descriptor() + self._gelman_rubin_max = self._gelman_rubin_max_descriptor() + self._effective_sample_size_min = self._effective_sample_size_min_descriptor() + self._best_log_posterior = self._best_log_posterior_descriptor() diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_lm.py b/src/easydiffraction/analysis/categories/minimizer/bumps_lm.py new file mode 100644 index 000000000..692081bea --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_lm.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the BUMPS lm minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class BumpsLmMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the BUMPS lm minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'bumps (lm)', + 'method_name': 'lm', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.BUMPS_LM, + description='BUMPS library with Levenberg-Marquardt method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/dfols.py b/src/easydiffraction/analysis/categories/minimizer/dfols.py new file mode 100644 index 000000000..81978f2ee --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/dfols.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the DFO-LS minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class DfolsMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the DFO-LS minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'dfols', + 'method_name': '', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.DFOLS, + description='DFO-LS library for derivative-free least-squares optimization', + ) diff --git a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/factory.py b/src/easydiffraction/analysis/categories/minimizer/factory.py similarity index 51% rename from src/easydiffraction/analysis/categories/bayesian_distribution_caches/factory.py rename to src/easydiffraction/analysis/categories/minimizer/factory.py index a45016f5b..b0dd8f702 100644 --- a/src/easydiffraction/analysis/categories/bayesian_distribution_caches/factory.py +++ b/src/easydiffraction/analysis/categories/minimizer/factory.py @@ -1,17 +1,18 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-distribution-caches factory.""" +"""Factory for persisted minimizer category items.""" from __future__ import annotations from typing import ClassVar +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum from easydiffraction.core.factory import FactoryBase -class BayesianDistributionCachesFactory(FactoryBase): - """Create Bayesian-distribution-cache collections by tag.""" +class MinimizerCategoryFactory(FactoryBase): + """Create minimizer category items by tag.""" _default_rules: ClassVar[dict] = { - frozenset(): 'default', + frozenset(): MinimizerTypeEnum.default().value, } diff --git a/src/easydiffraction/analysis/categories/minimizer/lmfit.py b/src/easydiffraction/analysis/categories/minimizer/lmfit.py new file mode 100644 index 000000000..99c07c64c --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/lmfit.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the default LMFIT minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class LmfitMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the default LMFIT minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'lmfit', + 'method_name': 'leastsq', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.LMFIT, + description='LMFIT library using the default Levenberg-Marquardt method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/lmfit_least_squares.py b/src/easydiffraction/analysis/categories/minimizer/lmfit_least_squares.py new file mode 100644 index 000000000..ad3e43f79 --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/lmfit_least_squares.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the LMFIT least_squares minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class LmfitLeastSquaresMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the LMFIT least_squares minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'lmfit (least_squares)', + 'method_name': 'least_squares', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.LMFIT_LEAST_SQUARES, + description="LMFIT library with SciPy's trust region reflective algorithm", + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/lmfit_leastsq.py b/src/easydiffraction/analysis/categories/minimizer/lmfit_leastsq.py new file mode 100644 index 000000000..ef43d500e --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/lmfit_leastsq.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the LMFIT leastsq minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.categories.minimizer.lsq_base import LeastSquaresMinimizerBase +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo + + +@MinimizerCategoryFactory.register +class LmfitLeastsqMinimizer(LeastSquaresMinimizerBase): + """Persisted settings for the LMFIT leastsq minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'lmfit (leastsq)', + 'method_name': 'leastsq', + } + + type_info = TypeInfo( + tag=MinimizerTypeEnum.LMFIT_LEASTSQ, + description='LMFIT library with Levenberg-Marquardt least squares method', + ) diff --git a/src/easydiffraction/analysis/categories/minimizer/lsq_base.py b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py new file mode 100644 index 000000000..bbe72b0fa --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Behavior helpers for deterministic minimizer categories.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import RangeValidator +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import IntegerDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +class LeastSquaresMinimizerBase(MinimizerCategoryBase): + """Shared behavior for least-squares minimizer categories.""" + + _default_max_iterations: ClassVar[int] = 1000 + _expected_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'max_iterations', + 'objective_name', + 'objective_value', + 'n_data_points', + 'n_parameters', + 'n_free_parameters', + 'degrees_of_freedom', + 'covariance_available', + 'correlation_available', + 'runtime_seconds', + 'iterations_performed', + 'exit_reason', + ) + _native_key_map: ClassVar[dict[str, str]] = { + 'max_iterations': 'max_iterations', + } + _setting_descriptor_names: ClassVar[tuple[str, ...]] = ('max_iterations',) + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'objective_name', + 'objective_value', + 'n_data_points', + 'n_parameters', + 'n_free_parameters', + 'degrees_of_freedom', + 'covariance_available', + 'correlation_available', + 'runtime_seconds', + 'iterations_performed', + 'exit_reason', + ) + + def __init__(self) -> None: + super().__init__() + self._max_iterations = self._max_iterations_descriptor(self._default_max_iterations) + self._objective_name = self._string_result_descriptor( + 'objective_name', + 'Objective function name for the persisted deterministic fit.', + ) + self._objective_value = self._numeric_result_descriptor( + 'objective_value', + 'Objective value for the persisted deterministic fit.', + ) + self._n_data_points = self._integer_result_descriptor( + 'n_data_points', + 'Number of data points used in the persisted deterministic fit.', + ) + self._n_parameters = self._integer_result_descriptor( + 'n_parameters', + 'Number of parameters considered in the persisted deterministic fit.', + ) + self._n_free_parameters = self._integer_result_descriptor( + 'n_free_parameters', + 'Number of free parameters in the persisted deterministic fit.', + ) + self._degrees_of_freedom = self._integer_result_descriptor( + 'degrees_of_freedom', + 'Degrees of freedom for the persisted deterministic fit.', + ) + self._covariance_available = self._bool_result_descriptor( + 'covariance_available', + 'Whether covariance was available for the persisted deterministic fit.', + ) + self._correlation_available = self._bool_result_descriptor( + 'correlation_available', + 'Whether correlations were available for the persisted deterministic fit.', + ) + self._runtime_seconds = self._numeric_result_descriptor( + 'runtime_seconds', + 'Runtime in seconds for the persisted deterministic fit.', + ) + self._iterations_performed = self._integer_result_descriptor( + 'iterations_performed', + 'Number of iterations performed by the persisted deterministic fit.', + ) + self._exit_reason = self._string_result_descriptor( + 'exit_reason', + 'Backend exit reason for the persisted deterministic fit.', + ) + + @staticmethod + def _max_iterations_descriptor(default: int) -> IntegerDescriptor: + """Create a max-iterations descriptor.""" + return IntegerDescriptor( + name='max_iterations', + description='Maximum solver iterations.', + value_spec=AttributeSpec(default=default, validator=RangeValidator(ge=1)), + cif_handler=CifHandler(names=['_minimizer.max_iterations']), + ) + + @staticmethod + def _string_result_descriptor(name: str, description: str) -> StringDescriptor: + """ + Create a string-valued result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than an empty string, matching the "no fit happened yet" + semantics shared with the numeric/integer/bool variants. + """ + return StringDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_minimizer.{name}']), + ) + + @staticmethod + def _numeric_result_descriptor( + name: str, + description: str, + *, + default: float | None = None, + allow_none: bool = True, + ) -> NumericDescriptor: + """Create a numeric result descriptor.""" + return NumericDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=default, allow_none=allow_none), + cif_handler=CifHandler(names=[f'_minimizer.{name}']), + ) + + @staticmethod + def _integer_result_descriptor(name: str, description: str) -> NumericDescriptor: + """ + Create an integer-like numeric result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than ``0``; the scientist audience reads ``0`` as a + degenerate result, not as "no fit yet". + """ + return NumericDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_minimizer.{name}']), + ) + + @staticmethod + def _bool_result_descriptor(name: str, description: str) -> BoolDescriptor: + """ + Create a boolean result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than ``false``; ``false`` would otherwise read as + "covariance/correlation was actively unavailable" instead of "no + fit happened yet". + """ + return BoolDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_minimizer.{name}']), + ) + + @property + def max_iterations(self) -> IntegerDescriptor: + """Maximum solver iterations.""" + return self._max_iterations + + @max_iterations.setter + def max_iterations(self, value: int) -> None: + self._max_iterations.value = value + + @property + def objective_name(self) -> StringDescriptor: + """ + Objective function name for the persisted deterministic fit. + """ + return self._objective_name + + def _set_objective_name(self, value: str | None) -> None: + self._objective_name.value = value + + @property + def objective_value(self) -> NumericDescriptor: + """Objective value for the persisted deterministic fit.""" + return self._objective_value + + def _set_objective_value(self, value: float | None) -> None: + self._objective_value.value = value + + @property + def n_data_points(self) -> NumericDescriptor: + """ + Number of data points used in the persisted deterministic fit. + """ + return self._n_data_points + + def _set_n_data_points(self, value: float | None) -> None: + self._n_data_points.value = value + + @property + def n_parameters(self) -> NumericDescriptor: + """Number of parameters in the persisted deterministic fit.""" + return self._n_parameters + + def _set_n_parameters(self, value: float | None) -> None: + self._n_parameters.value = value + + @property + def n_free_parameters(self) -> NumericDescriptor: + """ + Number of free parameters in the persisted deterministic fit. + """ + return self._n_free_parameters + + def _set_n_free_parameters(self, value: float | None) -> None: + self._n_free_parameters.value = value + + @property + def degrees_of_freedom(self) -> NumericDescriptor: + """Degrees of freedom for the persisted deterministic fit.""" + return self._degrees_of_freedom + + def _set_degrees_of_freedom(self, value: float | None) -> None: + self._degrees_of_freedom.value = value + + @property + def covariance_available(self) -> BoolDescriptor: + """Whether deterministic covariance was available.""" + return self._covariance_available + + def _set_covariance_available(self, *, value: bool | None) -> None: + self._covariance_available.value = value + + @property + def correlation_available(self) -> BoolDescriptor: + """Whether deterministic correlations were available.""" + return self._correlation_available + + def _set_correlation_available(self, *, value: bool | None) -> None: + self._correlation_available.value = value + + @property + def runtime_seconds(self) -> NumericDescriptor: + """Runtime in seconds for the persisted deterministic fit.""" + return self._runtime_seconds + + def _set_runtime_seconds(self, value: float | None) -> None: + self._runtime_seconds.value = value + + @property + def iterations_performed(self) -> NumericDescriptor: + """Number of iterations performed by the deterministic fit.""" + return self._iterations_performed + + def _set_iterations_performed(self, value: float | None) -> None: + self._iterations_performed.value = value + + @property + def exit_reason(self) -> StringDescriptor: + """Backend exit reason for the persisted deterministic fit.""" + return self._exit_reason + + def _set_exit_reason(self, value: str | None) -> None: + self._exit_reason.value = value diff --git a/src/easydiffraction/analysis/fit_helpers/__init__.py b/src/easydiffraction/analysis/fit_helpers/__init__.py index c7c53862a..18a61e30d 100644 --- a/src/easydiffraction/analysis/fit_helpers/__init__.py +++ b/src/easydiffraction/analysis/fit_helpers/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause from easydiffraction.analysis.fit_helpers.bayesian import BayesianFitResults -from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary from easydiffraction.analysis.fit_helpers.bayesian import PosteriorPredictiveSummary from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples from easydiffraction.analysis.fit_helpers.reporting import FitResults +from easydiffraction.core.posterior import PosteriorParameterSummary diff --git a/src/easydiffraction/analysis/fit_helpers/bayesian.py b/src/easydiffraction/analysis/fit_helpers/bayesian.py index ca191c835..20dd78a36 100644 --- a/src/easydiffraction/analysis/fit_helpers/bayesian.py +++ b/src/easydiffraction/analysis/fit_helpers/bayesian.py @@ -17,6 +17,7 @@ from easydiffraction.analysis.fit_helpers.reporting import FitResults from easydiffraction.analysis.fit_helpers.reporting import _build_parameter_row from easydiffraction.analysis.fit_helpers.reporting import _format_optional_float +from easydiffraction.core.posterior import PosteriorParameterSummary from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log from easydiffraction.utils.utils import render_table @@ -31,44 +32,6 @@ DiagnosticsMap = dict[str, object] | None -@dataclass(slots=True) -class PosteriorParameterSummary: - r""" - Posterior summary statistics for one fitted parameter. - - Attributes - ---------- - unique_name : str - Unique parameter name used across EasyDiffraction. - display_name : str - Human-readable label used in plots and tables. - best_sample_value : float - Highest-posterior sampled parameter value. - median : float - Posterior median value. - standard_deviation : float - Posterior standard deviation. - interval_68 : tuple[float, float] - Central 68% interval. - interval_95 : tuple[float, float] - Central 95% interval. - ess_bulk : float | None, default=None - Bulk effective sample size when available. - r_hat : float | None, default=None - Rank-normalized split-$\hat{R}$ when available. - """ - - unique_name: str - display_name: str - best_sample_value: float - median: float - standard_deviation: float - interval_68: tuple[float, float] - interval_95: tuple[float, float] - ess_bulk: float | None = None - r_hat: float | None = None - - @dataclass(slots=True) class PosteriorPredictiveSummary: """ @@ -189,6 +152,7 @@ def to_arviz(self) -> object: SummaryList = list[PosteriorParameterSummary] | None PredictiveMap = dict[str, PosteriorPredictiveSummary] | None +ArrayPayloadMap = dict[str, dict[str, np.ndarray]] | None @dataclass(kw_only=True) @@ -220,6 +184,10 @@ class BayesianFitResults(FitResults): Posterior summaries for each sampled parameter. posterior_predictive : PredictiveMap, default=None Posterior predictive summaries keyed by experiment name. + posterior_distribution_caches : ArrayPayloadMap, default=None + Cached posterior density arrays keyed by parameter name. + posterior_pair_caches : ArrayPayloadMap, default=None + Cached posterior pair-density arrays keyed by cache id. credible_interval_levels : IntervalLevels, default=DEFAULT_CI_LEVELS Interval levels available in the summaries. sampler_settings : SettingsMap, default=None @@ -243,6 +211,8 @@ class BayesianFitResults(FitResults): posterior_samples: PosteriorSamples | None = None posterior_parameter_summaries: SummaryList = None posterior_predictive: PredictiveMap = None + posterior_distribution_caches: ArrayPayloadMap = None + posterior_pair_caches: ArrayPayloadMap = None credible_interval_levels: IntervalLevels = DEFAULT_CI_LEVELS sampler_settings: SettingsMap = None convergence_diagnostics: DiagnosticsMap = None @@ -269,6 +239,14 @@ def __post_init__(self) -> None: self.posterior_predictive = ( dict(self.posterior_predictive) if self.posterior_predictive is not None else {} ) + self.posterior_distribution_caches = ( + dict(self.posterior_distribution_caches) + if self.posterior_distribution_caches is not None + else {} + ) + self.posterior_pair_caches = ( + dict(self.posterior_pair_caches) if self.posterior_pair_caches is not None else {} + ) self.sampler_settings = dict(self.sampler_settings) if self.sampler_settings else {} self.convergence_diagnostics = ( dict(self.convergence_diagnostics) if self.convergence_diagnostics is not None else {} diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index 72d89a6ce..c7c79917f 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -218,6 +218,11 @@ def fit( use_physical_limits=use_physical_limits, random_seed=random_seed, ) + # Stop the timer and backfill results.fitting_time now so + # post-processing projects a real duration into persisted + # categories. The live display is still torn down in the + # finally below. + self.minimizer._finalize_timing() self._postprocess_fit_results( analysis=analysis, experiments=experiments, diff --git a/src/easydiffraction/analysis/minimizers/base.py b/src/easydiffraction/analysis/minimizers/base.py index 2d8bc83f9..438ed9eaa 100644 --- a/src/easydiffraction/analysis/minimizers/base.py +++ b/src/easydiffraction/analysis/minimizers/base.py @@ -43,6 +43,7 @@ def __init__( self._fitting_time: float | None = None self._resolved_random_seed: int | None = None self._tracking_active: bool = False + self._timing_finalized: bool = False self._deferred_warning_messages: list[str] = [] self.tracker: FitProgressTracker = FitProgressTracker() @@ -73,21 +74,35 @@ def _start_tracking( self.tracker.reset() self.tracker._verbosity = verbosity self._tracking_active = True + self._timing_finalized = False self._deferred_warning_messages = [] self.tracker.start_tracking(minimizer_name, mode=self._tracking_mode()) self.tracker.start_timer() + def _finalize_timing(self) -> None: + """ + Stop the timer and propagate fitting_time to the result. + + Idempotent: subsequent calls within the same run are no-ops, so + callers can finalize timing before post-processing without the + later display teardown overwriting the recorded duration. + """ + if not self._tracking_active or self._timing_finalized: + return + self.tracker.stop_timer() + if self.result is not None: + self.result.fitting_time = self.tracker.fitting_time + self._timing_finalized = True + def _stop_tracking(self) -> None: """Stop timer and finalize tracking.""" if not self._tracking_active: self._emit_deferred_warnings() return + self._finalize_timing() self._tracking_active = False - self.tracker.stop_timer() self.tracker.finish_tracking() - if self.result is not None: - self.result.fitting_time = self.tracker.fitting_time self._emit_deferred_warnings() def _warn_after_tracking(self, message: str) -> None: diff --git a/src/easydiffraction/analysis/minimizers/enums.py b/src/easydiffraction/analysis/minimizers/enums.py index 0d5bb086d..6931960e0 100644 --- a/src/easydiffraction/analysis/minimizers/enums.py +++ b/src/easydiffraction/analysis/minimizers/enums.py @@ -53,6 +53,15 @@ def description(self) -> str: return descriptions.get(self, '') +class InitializationMethodEnum(StrEnum): + """Supported Bayesian sampler initialization methods.""" + + LATIN_HYPERCUBE = 'latin_hypercube' + BALL = 'ball' + UNIFORM = 'uniform' + PRIOR = 'prior' + + class DreamPopulationInitializationEnum(StrEnum): """Supported DREAM population initializers.""" diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py index a43b537a3..2c48f40fc 100644 --- a/src/easydiffraction/analysis/sequential.py +++ b/src/easydiffraction/analysis/sequential.py @@ -140,7 +140,7 @@ def _fit_worker( # (internal, no console output) from easydiffraction.analysis.fitting import Fitter # noqa: PLC0415 - expt._set_calculator_type(template.calculator_tag, announce=False) + expt._swap_calculator(template.calculator_tag, announce=False) project.analysis.fitter = Fitter(template.minimizer_tag) # 10. Fit @@ -647,8 +647,8 @@ def _build_template(project: object) -> SequentialFitTemplate: alias_defs=alias_defs, constraint_defs=constraint_defs, constraints_enabled=project.analysis.constraints.enabled, - minimizer_tag=project.analysis.fitting.minimizer_type.value or 'lmfit', - calculator_tag=experiment.calculation.calculator_type.value, + minimizer_tag=project.analysis.minimizer.type or 'lmfit', + calculator_tag=experiment.calculator.type, diffrn_extract_rules=diffrn_extract_rules, diffrn_field_names=diffrn_field_names, ) diff --git a/src/easydiffraction/core/category.py b/src/easydiffraction/core/category.py index d2d95fb80..93afa631f 100644 --- a/src/easydiffraction/core/category.py +++ b/src/easydiffraction/core/category.py @@ -87,7 +87,6 @@ def help(self) -> None: from easydiffraction.utils.utils import render_table # noqa: PLC0415 cls = type(self) - console.paragraph(f"Help for '{cls.__name__}'") # Deduplicate properties seen: dict = {} @@ -98,8 +97,6 @@ def help(self) -> None: # Split into descriptor-backed and other param_rows = [] other_rows = [] - p_idx = 0 - o_idx = 0 for key in sorted(seen): prop = seen[key] try: @@ -107,11 +104,9 @@ def help(self) -> None: except (AttributeError, TypeError, ValueError): val = None if isinstance(val, GenericDescriptorBase): - p_idx += 1 type_str = 'string' if isinstance(val, GenericStringDescriptor) else 'numeric' - writable = '✓' if prop.fset else '✗' + writable = '✓' if prop.fset else '' param_rows.append([ - str(p_idx), key, type_str, str(val.value), @@ -119,16 +114,14 @@ def help(self) -> None: val.description or '', ]) else: - o_idx += 1 - writable = '✓' if prop.fset else '✗' + writable = '✓' if prop.fset else '' doc = self._first_sentence(prop.fget.__doc__ if prop.fget else None) - other_rows.append([str(o_idx), key, writable, doc]) + other_rows.append([key, writable, doc]) if param_rows: console.paragraph('Parameters') render_table( columns_headers=[ - '#', 'Name', 'Type', 'Value', @@ -136,7 +129,6 @@ def help(self) -> None: 'Description', ], columns_alignment=[ - 'right', 'left', 'left', 'right', @@ -147,16 +139,14 @@ def help(self) -> None: ) if other_rows: - console.paragraph('Other properties') + console.paragraph('Properties') render_table( columns_headers=[ - '#', 'Name', 'Writable', 'Description', ], columns_alignment=[ - 'right', 'left', 'center', 'left', @@ -166,15 +156,15 @@ def help(self) -> None: methods = dict(cls._iter_methods()) method_rows = [] - for i, key in enumerate(sorted(methods), 1): + for key in sorted(methods): doc = self._first_sentence(getattr(methods[key], '__doc__', None)) - method_rows.append([str(i), f'{key}()', doc]) + method_rows.append([f'{key}()', doc]) if method_rows: console.paragraph('Methods') render_table( - columns_headers=['#', 'Name', 'Description'], - columns_alignment=['right', 'left', 'left'], + columns_headers=['Name', 'Description'], + columns_alignment=['left', 'left'], columns_data=method_rows, ) @@ -235,6 +225,11 @@ def parameters(self) -> list: params.extend(item.parameters) return params + @property + def scalar_descriptors(self) -> list: + """Collection-level descriptors serialized outside the loop.""" + return [v for v in vars(self).values() if isinstance(v, GenericDescriptorBase)] + @property def as_cif(self) -> str: """Return CIF representation of this object.""" diff --git a/src/easydiffraction/core/collection.py b/src/easydiffraction/core/collection.py index 356469354..625ff6610 100644 --- a/src/easydiffraction/core/collection.py +++ b/src/easydiffraction/core/collection.py @@ -170,12 +170,12 @@ def help(self) -> None: if self._items: console.paragraph(f'Items ({len(self._items)})') rows = [] - for i, item in enumerate(self._items, 1): + for item in self._items: key = self._key_for(item) - rows.append([str(i), str(key), f"['{key}']"]) + rows.append([str(key), f"['{key}']"]) render_table( - columns_headers=['#', 'Name', 'Access'], - columns_alignment=['right', 'left', 'left'], + columns_headers=['Name', 'Access'], + columns_alignment=['left', 'left'], columns_data=rows, ) else: diff --git a/src/easydiffraction/core/guard.py b/src/easydiffraction/core/guard.py index d1f8d4fd4..8176cfa23 100644 --- a/src/easydiffraction/core/guard.py +++ b/src/easydiffraction/core/guard.py @@ -227,7 +227,6 @@ def help(self) -> None: from easydiffraction.utils.utils import render_table # noqa: PLC0415 cls = type(self) - console.paragraph(f"Help for '{cls.__name__}'") # Deduplicate (MRO may yield the same name) seen: dict = {} @@ -242,29 +241,29 @@ def help(self) -> None: property_names, method_names = _apply_help_filter(self, property_names, method_names) prop_rows = [] - for i, key in enumerate(property_names, 1): + for key in property_names: prop = seen[key] - writable = '✓' if prop.fset else '✗' + writable = '✓' if prop.fset else '' doc = self._first_sentence(prop.fget.__doc__ if prop.fget else None) - prop_rows.append([str(i), key, writable, doc]) + prop_rows.append([key, writable, doc]) if prop_rows: console.paragraph('Properties') render_table( - columns_headers=['#', 'Name', 'Writable', 'Description'], - columns_alignment=['right', 'left', 'center', 'left'], + columns_headers=['Name', 'Writable', 'Description'], + columns_alignment=['left', 'center', 'left'], columns_data=prop_rows, ) method_rows = [] - for i, key in enumerate(method_names, 1): + for key in method_names: doc = self._first_sentence(getattr(methods[key], '__doc__', None)) - method_rows.append([str(i), f'{key}()', doc]) + method_rows.append([f'{key}()', doc]) if method_rows: console.paragraph('Methods') render_table( - columns_headers=['#', 'Name', 'Description'], - columns_alignment=['right', 'left', 'left'], + columns_headers=['Name', 'Description'], + columns_alignment=['left', 'left'], columns_data=method_rows, ) diff --git a/src/easydiffraction/core/posterior.py b/src/easydiffraction/core/posterior.py new file mode 100644 index 000000000..842cf6920 --- /dev/null +++ b/src/easydiffraction/core/posterior.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Core posterior summary value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class PosteriorParameterSummary: + r""" + Posterior summary statistics for one fitted parameter. + + Attributes + ---------- + unique_name : str + Unique parameter name used across EasyDiffraction. + display_name : str + Human-readable label used in plots and tables. + best_sample_value : float + Highest-posterior sampled parameter value. + median : float + Posterior median value. + standard_deviation : float + Posterior standard deviation. + interval_68 : tuple[float, float] + Central 68% interval. + interval_95 : tuple[float, float] + Central 95% interval. + ess_bulk : float | None, default=None + Bulk effective sample size when available. + r_hat : float | None, default=None + Rank-normalized split-$\hat{R}$ when available. + """ + + unique_name: str + display_name: str + best_sample_value: float + median: float + standard_deviation: float + interval_68: tuple[float, float] + interval_95: tuple[float, float] + ess_bulk: float | None = None + r_hat: float | None = None diff --git a/src/easydiffraction/core/switchable.py b/src/easydiffraction/core/switchable.py new file mode 100644 index 000000000..a260b3ec6 --- /dev/null +++ b/src/easydiffraction/core/switchable.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import ClassVar + +from easydiffraction.utils.logging import console +from easydiffraction.utils.utils import render_table + + +class SwitchableCategoryBase(ABC): + """Behavior-only mixin for category-owned selectors.""" + + _parent: object | None = None + _category_code: ClassVar[str] + _owner_attr_name: ClassVar[str] + _swap_method_name: ClassVar[str] + + @property + def type(self) -> str: + """Active factory tag for this category.""" + return self._type.value + + @type.setter + def type(self, value: str) -> None: + """Request an owner-mediated switch to a new category type.""" + if self._parent is None: + msg = f'{type(self).__name__} is detached; cannot change type on a stale instance.' + raise RuntimeError(msg) + + live = getattr(self._parent, self._owner_attr_name) + if live is not self: + msg = ( + f'{type(self).__name__} is no longer the live category ' + f'on its owner; obtain a fresh reference via ' + f'owner.{self._owner_attr_name}.' + ) + raise RuntimeError(msg) + + canonical = self._canonicalize(value) + getattr(self._parent, self._swap_method_name)(canonical) + + @staticmethod + def _canonicalize(value: str) -> str: + """ + Resolve a user-supplied tag to its canonical factory tag. + + Parameters + ---------- + value : str + User-supplied selector value. + + Returns + ------- + str + Canonical selector value. + """ + return value + + @abstractmethod + def _supported_types( + self, + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """ + Return supported ``(tag, description)`` pairs. + + Parameters + ---------- + filters : dict[str, object] + Owner-provided context filters for this category. + + Returns + ------- + list[tuple[str, str]] + Supported type tags and descriptions. + + Raises + ------ + NotImplementedError + If a concrete switchable category does not implement the + supported-types lookup. + """ + raise NotImplementedError + + def show_supported(self) -> None: + """Print supported types and mark the active one.""" + filters = self._parent._supported_filters_for(self) if self._parent is not None else {} + current = self.type + columns_data = [ + ['*' if tag == current else '', tag, description] + for tag, description in self._supported_types(filters) + ] + + category_name = self._category_code.replace('_', ' ').title() + console.paragraph(f'{category_name} types') + render_table( + columns_headers=['', 'Type', 'Description'], + columns_alignment=['left', 'left', 'left'], + columns_data=columns_data, + ) diff --git a/src/easydiffraction/core/validation.py b/src/easydiffraction/core/validation.py index 314d7e94c..b937ac081 100644 --- a/src/easydiffraction/core/validation.py +++ b/src/easydiffraction/core/validation.py @@ -17,6 +17,8 @@ from easydiffraction.core.diagnostic import Diagnostics +_MISSING_DEFAULT = object() + # ====================================================================== # Shared constants # ====================================================================== @@ -291,16 +293,21 @@ class AttributeSpec: def __init__( self, *, - default: object = None, + default: object = _MISSING_DEFAULT, data_type: DataTypes | None = None, validator: ValidatorBase | None = None, allow_none: bool = False, ) -> None: - self.default = default + self.has_default = default is not _MISSING_DEFAULT + self.default = None if default is _MISSING_DEFAULT else default self.allow_none = allow_none self._data_type_validator = TypeValidator(data_type) if data_type else None self._validator = validator + def default_value(self) -> object: + """Return the resolved static default value.""" + return self.default() if callable(self.default) else self.default + def validated( self, value: object, @@ -315,7 +322,7 @@ def validated( """ val = value # Evaluate callable defaults dynamically - default = self.default() if callable(self.default) else self.default + default = self.default_value() # Type validation if self._data_type_validator: diff --git a/src/easydiffraction/core/variable.py b/src/easydiffraction/core/variable.py index 074afb714..c112d716a 100644 --- a/src/easydiffraction/core/variable.py +++ b/src/easydiffraction/core/variable.py @@ -18,6 +18,7 @@ from easydiffraction.utils.logging import log if TYPE_CHECKING: + from easydiffraction.core.posterior import PosteriorParameterSummary from easydiffraction.io.cif.handler import CifHandler # ====================================================================== @@ -94,8 +95,7 @@ def __init__( # Skip validation — defaults are trusted. # Callable is needed for dynamic defaults like SpaceGroup # it_coordinate_system_code, and similar cases. - default = value_spec.default - self._value = default() if callable(default) else default + self._value = value_spec.default_value() def __str__(self) -> str: """Return the string representation of this descriptor.""" @@ -320,6 +320,7 @@ def __init__( self._user_constrained = self._user_constrained_spec.default self._symmetry_constrained_spec = self._BOOL_SPEC_TEMPLATE self._symmetry_constrained = self._symmetry_constrained_spec.default + self._posterior: PosteriorParameterSummary | None = None def _physical_lower_bound(self) -> float: """ @@ -437,6 +438,15 @@ def uncertainty(self, v: float | None) -> None: v, name=f'{self.unique_name}.uncertainty', current=self._uncertainty ) + @property + def posterior(self) -> PosteriorParameterSummary | None: + """Posterior summary from a Bayesian fit, if available.""" + return self._posterior + + def _set_posterior(self, value: PosteriorParameterSummary | None) -> None: + """Set the posterior summary for internal callers.""" + self._posterior = value + @property def fit_min(self) -> float: """Lower fitting bound.""" diff --git a/src/easydiffraction/datablocks/experiment/categories/background/base.py b/src/easydiffraction/datablocks/experiment/categories/background/base.py index 433c4aa71..63f7b5da8 100644 --- a/src/easydiffraction/datablocks/experiment/categories/background/base.py +++ b/src/easydiffraction/datablocks/experiment/categories/background/base.py @@ -6,9 +6,16 @@ from abc import abstractmethod from easydiffraction.core.category import CategoryCollection +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.datablocks.experiment.categories.background.enums import BackgroundTypeEnum +from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory +from easydiffraction.io.cif.handler import CifHandler -class BackgroundBase(CategoryCollection): +class BackgroundBase(CategoryCollection, SwitchableCategoryBase): """ Abstract base for background subcategories in experiments. @@ -16,6 +23,43 @@ class BackgroundBase(CategoryCollection): compute background intensities on the experiment grid. """ + _category_code = 'background' + _owner_attr_name = 'background' + _swap_method_name = '_swap_background' + + def __init__(self, item_type: type) -> None: + super().__init__(item_type=item_type) + + type_info = getattr(type(self), 'type_info', None) + default_tag = type_info.tag if type_info is not None else '' + self._type: StringDescriptor = StringDescriptor( + name='type', + description='Active background type tag', + value_spec=AttributeSpec( + default=default_tag, + validator=MembershipValidator( + allowed=[member.value for member in BackgroundTypeEnum], + ), + ), + cif_handler=CifHandler(names=['_background.type']), + ) + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return background types supported for owner filters.""" + return [ + (klass.type_info.tag, klass.type_info.description) + for klass in BackgroundFactory.supported_for( + calculator=filters.get('calculator'), + sample_form=filters.get('sample_form'), + scattering_type=filters.get('scattering_type'), + beam_mode=filters.get('beam_mode'), + radiation_probe=filters.get('radiation_probe'), + ) + ] + # TODO: Consider moving to CategoryCollection @abstractmethod def show(self) -> None: diff --git a/src/easydiffraction/datablocks/experiment/categories/calculation/__init__.py b/src/easydiffraction/datablocks/experiment/categories/calculation/__init__.py deleted file mode 100644 index 70ffd56cb..000000000 --- a/src/easydiffraction/datablocks/experiment/categories/calculation/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Experiment calculation category exports.""" - -from __future__ import annotations - -from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation -from easydiffraction.datablocks.experiment.categories.calculation.factory import CalculationFactory diff --git a/src/easydiffraction/datablocks/experiment/categories/calculation/factory.py b/src/easydiffraction/datablocks/experiment/categories/calculation/factory.py deleted file mode 100644 index 721125959..000000000 --- a/src/easydiffraction/datablocks/experiment/categories/calculation/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Factory for experiment calculation categories.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class CalculationFactory(FactoryBase): - """Create experiment calculation category instances.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/datablocks/experiment/categories/calculator/__init__.py b/src/easydiffraction/datablocks/experiment/categories/calculator/__init__.py new file mode 100644 index 000000000..2958fd387 --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/calculator/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Experiment calculator category exports.""" + +from __future__ import annotations + +from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator +from easydiffraction.datablocks.experiment.categories.calculator.factory import ( + CalculatorCategoryFactory, +) diff --git a/src/easydiffraction/datablocks/experiment/categories/calculation/default.py b/src/easydiffraction/datablocks/experiment/categories/calculator/default.py similarity index 52% rename from src/easydiffraction/datablocks/experiment/categories/calculation/default.py rename to src/easydiffraction/datablocks/experiment/categories/calculator/default.py index ae54663a1..9f11d1540 100644 --- a/src/easydiffraction/datablocks/experiment/categories/calculation/default.py +++ b/src/easydiffraction/datablocks/experiment/categories/calculator/default.py @@ -1,65 +1,55 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Experiment calculation category.""" +"""Experiment calculator category.""" from __future__ import annotations from easydiffraction.core.category import CategoryItem from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.switchable import SwitchableCategoryBase from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.validation import MembershipValidator from easydiffraction.core.variable import StringDescriptor -from easydiffraction.datablocks.experiment.categories.calculation.factory import CalculationFactory +from easydiffraction.datablocks.experiment.categories.calculator.factory import ( + CalculatorCategoryFactory, +) from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.io.cif.handler import CifHandler from easydiffraction.io.cif.parse import read_cif_str -from easydiffraction.utils.logging import console -from easydiffraction.utils.utils import render_table -@CalculationFactory.register -class Calculation(CategoryItem): +@CalculatorCategoryFactory.register +class Calculator(CategoryItem, SwitchableCategoryBase): """Calculator selection and access for an experiment.""" - _category_code = 'calculation' + _category_code = 'calculator' + _owner_attr_name = 'calculator' + _swap_method_name = '_swap_calculator' type_info = TypeInfo( tag='default', - description='Experiment calculation category', + description='Experiment calculator category', ) def __init__( self, *, - calculator_type: str, + type: str, ) -> None: super().__init__() - self._calculator_type = StringDescriptor( - name='calculator_type', - description='Calculation backend type', + self._type = StringDescriptor( + name='type', + description='Calculator backend type', value_spec=AttributeSpec( - default=calculator_type, + default=type, validator=MembershipValidator( allowed=[member.value for member in CalculatorEnum], ), ), - cif_handler=CifHandler(names=['_calculation.calculator_type']), + cif_handler=CifHandler(names=['_calculator.type']), ) - @property - def calculator_type(self) -> StringDescriptor: - """Calculation backend type.""" - return self._calculator_type - - @calculator_type.setter - def calculator_type(self, value: str) -> None: - parent = getattr(self, '_parent', None) - if parent is None: - self._calculator_type.value = value - return - parent._set_calculator_type(value) - @property def calculator(self) -> object | None: """Live calculator backend instance.""" @@ -67,40 +57,37 @@ def calculator(self) -> object | None: if parent is None: return None if getattr(parent, '_calculator', None) is None: - parent._resolve_calculation() + parent._resolve_calculator() return parent._calculator - def show_calculator_types(self) -> None: - """Print supported calculator backends and mark current type.""" + def _supported_types( + self, + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return calculator backends supported by this experiment.""" + del filters from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 parent = getattr(self, '_parent', None) - current = self.calculator_type.value if parent is None: supported_tags = CalculatorFactory.supported_tags() else: supported_tags = parent._supported_calculator_tags() all_classes = CalculatorFactory._supported_map() - columns_data = [ - ['*' if tag == current else '', tag, cls.type_info.description] + return [ + (tag, cls.type_info.description) for tag, cls in all_classes.items() if tag in supported_tags ] - console.paragraph('Calculator types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) def from_cif(self, block: object, idx: int = 0) -> None: - """Populate this calculation category from a CIF block.""" + """Populate this calculator category from a CIF block.""" del idx - tag = read_cif_str(block, '_calculation.calculator_type') + tag = read_cif_str(block, '_calculator.type') if tag is None: return parent = getattr(self, '_parent', None) if parent is None: - self._calculator_type.value = tag + self._type.value = tag return - parent._set_calculator_type(tag, announce=False) + parent._swap_calculator(tag, announce=False, strict=False) diff --git a/src/easydiffraction/analysis/categories/bayesian_convergence/factory.py b/src/easydiffraction/datablocks/experiment/categories/calculator/factory.py similarity index 67% rename from src/easydiffraction/analysis/categories/bayesian_convergence/factory.py rename to src/easydiffraction/datablocks/experiment/categories/calculator/factory.py index fbe5da383..45dfc87c3 100644 --- a/src/easydiffraction/analysis/categories/bayesian_convergence/factory.py +++ b/src/easydiffraction/datablocks/experiment/categories/calculator/factory.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-convergence factory.""" +"""Factory for experiment calculator categories.""" from __future__ import annotations @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class BayesianConvergenceFactory(FactoryBase): - """Create Bayesian-convergence categories by tag.""" +class CalculatorCategoryFactory(FactoryBase): + """Create experiment calculator category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py index 1d5c866b8..a807cc155 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py @@ -386,7 +386,7 @@ def _update( experiments = experiment._parent project = experiments._parent structures = project.structures - calculator = experiment.calculation.calculator + calculator = experiment.calculator.calculator refln = experiment.refln calc, refln_records, missing_refln_records = self._phase_calculation_results( diff --git a/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py index 58ef0c74b..4b3930427 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/total_pd.py @@ -246,7 +246,7 @@ def _update( experiments = experiment._parent project = experiments._parent structures = project.structures - calculator = experiment.calculation.calculator + calculator = experiment.calculator.calculator initial_calc = np.zeros_like(self.x) calc = initial_calc diff --git a/src/easydiffraction/datablocks/experiment/categories/extinction/base.py b/src/easydiffraction/datablocks/experiment/categories/extinction/base.py new file mode 100644 index 000000000..e5f738ca0 --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/extinction/base.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Base class for extinction correction categories.""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.datablocks.experiment.categories.extinction.factory import ExtinctionFactory +from easydiffraction.io.cif.handler import CifHandler + + +class ExtinctionBase(CategoryItem, SwitchableCategoryBase): + """Base class for extinction correction categories.""" + + _category_code = 'extinction' + _owner_attr_name = 'extinction' + _swap_method_name = '_swap_extinction' + + def __init__(self) -> None: + super().__init__() + + type_info = getattr(type(self), 'type_info', None) + default_tag = type_info.tag if type_info is not None else '' + self._type: StringDescriptor = StringDescriptor( + name='type', + description='Active extinction type tag', + value_spec=AttributeSpec( + default=default_tag, + validator=MembershipValidator( + allowed=ExtinctionFactory.supported_tags(), + ), + ), + cif_handler=CifHandler(names=['_extinction.type']), + ) + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return extinction types supported for owner filters.""" + return [ + (klass.type_info.tag, klass.type_info.description) + for klass in ExtinctionFactory.supported_for( + calculator=filters.get('calculator'), + sample_form=filters.get('sample_form'), + scattering_type=filters.get('scattering_type'), + beam_mode=filters.get('beam_mode'), + radiation_probe=filters.get('radiation_probe'), + ) + ] diff --git a/src/easydiffraction/datablocks/experiment/categories/extinction/becker_coppens.py b/src/easydiffraction/datablocks/experiment/categories/extinction/becker_coppens.py index d5ffa1578..531f7a199 100644 --- a/src/easydiffraction/datablocks/experiment/categories/extinction/becker_coppens.py +++ b/src/easydiffraction/datablocks/experiment/categories/extinction/becker_coppens.py @@ -6,7 +6,6 @@ from __future__ import annotations -from easydiffraction.core.category import CategoryItem from easydiffraction.core.metadata import CalculatorSupport from easydiffraction.core.metadata import Compatibility from easydiffraction.core.metadata import TypeInfo @@ -15,6 +14,7 @@ from easydiffraction.core.validation import RangeValidator from easydiffraction.core.variable import Parameter from easydiffraction.core.variable import StringDescriptor +from easydiffraction.datablocks.experiment.categories.extinction.base import ExtinctionBase from easydiffraction.datablocks.experiment.categories.extinction.factory import ExtinctionFactory from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import ExtinctionModelEnum @@ -23,7 +23,7 @@ @ExtinctionFactory.register -class BeckerCoppensExtinction(CategoryItem): +class BeckerCoppensExtinction(ExtinctionBase): """ Becker-Coppens spherical extinction correction for single crystals. @@ -36,8 +36,6 @@ class BeckerCoppensExtinction(CategoryItem): (in arc-minutes, as expected by CrysPy). """ - _category_code = 'extinction' - type_info = TypeInfo( tag='becker-coppens', description='Becker-Coppens isotropic extinction correction', diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/base.py b/src/easydiffraction/datablocks/experiment/categories/peak/base.py index 330d34624..227c5a37c 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/base.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/base.py @@ -5,36 +5,87 @@ from __future__ import annotations from easydiffraction.core.category import CategoryItem +from easydiffraction.core.switchable import SwitchableCategoryBase from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator from easydiffraction.core.variable import StringDescriptor +from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory +from easydiffraction.datablocks.experiment.item.enums import PeakProfileTypeEnum from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.utils.logging import console +from easydiffraction.utils.utils import render_table -class PeakBase(CategoryItem): +class PeakBase(CategoryItem, SwitchableCategoryBase): """Base class for peak profile categories.""" _category_code = 'peak' + _owner_attr_name = 'peak' + _swap_method_name = '_swap_peak' def __init__(self) -> None: super().__init__() type_info = getattr(type(self), 'type_info', None) default_tag = type_info.tag if type_info is not None else '' - self._profile_type: StringDescriptor = StringDescriptor( - name='profile_type', + self._type: StringDescriptor = StringDescriptor( + name='type', description='Active peak profile type tag', - value_spec=AttributeSpec(default=default_tag), - cif_handler=CifHandler(names=['_peak.profile_type']), + value_spec=AttributeSpec( + default=default_tag, + validator=MembershipValidator( + allowed=[member.value for member in PeakProfileTypeEnum], + ), + ), + cif_handler=CifHandler(names=['_peak.type']), ) - @property - def profile_type(self) -> StringDescriptor: - """ - CIF identifier for the active peak profile type. - - Returns - ------- - StringDescriptor - The descriptor holding the profile type tag string. - """ - return self._profile_type + def _canonicalize(self, value: str) -> str: + """Resolve a context-local peak alias to a canonical tag.""" + context = self._parent._peak_profile_context() if self._parent is not None else {} + return PeakFactory._canonical_tag_for(value, **context) + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return peak profile types supported for owner filters.""" + return [ + (klass.type_info.tag, klass.type_info.description) + for klass in PeakFactory.supported_for( + calculator=filters.get('calculator'), + sample_form=filters.get('sample_form'), + scattering_type=filters.get('scattering_type'), + beam_mode=filters.get('beam_mode'), + radiation_probe=filters.get('radiation_probe'), + ) + ] + + def show_supported(self) -> None: + """Print supported peak profiles with context-local aliases.""" + filters = self._parent._supported_filters_for(self) if self._parent is not None else {} + context = self._parent._peak_profile_context() if self._parent is not None else {} + rows = self._supported_types(filters) + aliases = [PeakFactory._local_alias_for(tag, **context) for tag, _ in rows] + show_aliases = any(alias != tag for alias, (tag, _) in zip(aliases, rows, strict=True)) + + if show_aliases: + columns_headers = ['', 'Type', 'Alias', 'Description'] + columns_alignment = ['left', 'left', 'left', 'left'] + columns_data = [ + ['*' if tag == self.type else '', tag, alias, description] + for alias, (tag, description) in zip(aliases, rows, strict=True) + ] + else: + columns_headers = ['', 'Type', 'Description'] + columns_alignment = ['left', 'left', 'left'] + columns_data = [ + ['*' if tag == self.type else '', tag, description] for tag, description in rows + ] + + console.paragraph('Peak types') + render_table( + columns_headers=columns_headers, + columns_alignment=columns_alignment, + columns_data=columns_data, + ) diff --git a/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py index 8b29900d4..12ba577d9 100644 --- a/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py +++ b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py @@ -332,7 +332,7 @@ def _update( experiments = experiment._parent project = experiments._parent structures = project.structures - calculator = experiment.calculation.calculator + calculator = experiment.calculator.calculator linked_crystal = experiment.linked_crystal linked_crystal_id = experiment.linked_crystal.id.value diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index 563a62229..b0120d0de 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -9,7 +9,8 @@ from typing import Any from easydiffraction.core.datablock import DatablockItem -from easydiffraction.datablocks.experiment.categories.calculation import CalculationFactory +from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory +from easydiffraction.datablocks.experiment.categories.calculator import CalculatorCategoryFactory from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory from easydiffraction.datablocks.experiment.categories.diffrn.factory import DiffrnFactory from easydiffraction.datablocks.experiment.categories.excluded_regions.factory import ( @@ -30,7 +31,6 @@ from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log from easydiffraction.utils.utils import render_cif -from easydiffraction.utils.utils import render_table if TYPE_CHECKING: from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType @@ -69,16 +69,170 @@ def __init__( self._name = name self._type = type self._calculator = None - self._calculator_type: str | None = None self._identity.datablock_entry_name = lambda: self.name self._diffrn_type: str = DiffrnFactory.default_tag() self._diffrn = DiffrnFactory.create(self._diffrn_type) - self._calculation = CalculationFactory.create( + self._calculator_category = CalculatorCategoryFactory.create( 'default', - calculator_type=self._default_calculator_tag(), + type=self._default_calculator_tag(), ) - self._calculation._parent = self + self._attach_category_parents() + + def _attach_category_parents(self) -> None: + """Link owned categories back to this experiment object.""" + for category in [ + self._type, + getattr(self, '_diffrn', None), + getattr(self, '_calculator_category', None), + getattr(self, '_extinction', None), + getattr(self, '_linked_crystal', None), + getattr(self, '_instrument', None), + getattr(self, '_refln', None), + getattr(self, '_linked_phases', None), + getattr(self, '_excluded_regions', None), + getattr(self, '_data', None), + getattr(self, '_peak', None), + getattr(self, '_background', None), + ]: + if category is not None: + category._parent = self + + def _supported_filters_for(self, category: object) -> dict[str, object]: + """Return owner context filters for a switchable category.""" + calculator = self.calculator.type + if category is getattr(self, '_background', None): + return {'calculator': calculator} + if category is getattr(self, '_extinction', None): + return {'calculator': calculator} + if category is getattr(self, '_peak', None): + return { + 'calculator': calculator, + 'sample_form': self.type.sample_form.value, + 'scattering_type': self.type.scattering_type.value, + 'beam_mode': self.type.beam_mode.value, + } + return {} + + def _swap_calculator( + self, + new_type: str, + *, + announce: bool = True, + strict: bool = True, + ) -> None: + """Switch the active calculator backend.""" + from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 + + supported = self._supported_calculator_tags() + if new_type not in supported: + msg = ( + f"Unsupported calculator '{new_type}' for experiment " + f"'{self.name}'. Supported: {supported}. " + f"For more information, use 'calculator.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + if self._calculator_category._type.value == new_type and self._calculator is not None: + if announce: + console.paragraph(f"Calculator for experiment '{self.name}' already set to") + console.print(new_type) + return + self._calculator = CalculatorFactory.create(new_type) + self._calculator_category._type.value = new_type + if announce: + console.paragraph(f"Calculator for experiment '{self.name}' changed to") + console.print(new_type) + + def _swap_peak(self, new_type: str) -> None: + """Switch the active peak category.""" + self._replace_peak_profile(new_type, announce=True) + + def _swap_background(self, new_type: str) -> None: + """Switch the active background category.""" + self._replace_background(new_type, announce=True) + + def _replace_background( + self, + new_type: str, + *, + announce: bool, + strict: bool = True, + ) -> None: + """Replace the active background category.""" + supported = BackgroundFactory.supported_for( + **self._supported_filters_for(self.background), + ) + supported_tags = [klass.type_info.tag for klass in supported] + if new_type not in supported_tags: + msg = ( + f"Unsupported background type '{new_type}'. " + f'Supported: {supported_tags}. ' + f"For more information, use 'background.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + + if self._background._type.value == new_type: + if announce: + console.paragraph(f"Background type for experiment '{self.name}' already set to") + console.print(new_type) + return + + if len(self._background) > 0 and announce: + log.warning( + f'Switching background type discards {len(self._background)} ' + f'existing background point(s).', + ) + + old_background = self._background + self._background = BackgroundFactory.create(new_type) + old_background._parent = None + self._background._parent = self + self._background._type.value = new_type + if announce: + console.paragraph(f"Background type for experiment '{self.name}' changed to") + console.print(new_type) + + def _swap_extinction(self, new_type: str) -> None: + """Switch the active extinction category.""" + self._replace_extinction(new_type, announce=True) + + def _replace_extinction( + self, + new_type: str, + *, + announce: bool, + strict: bool = True, + ) -> None: + """Replace the active extinction category.""" + supported = ExtinctionFactory.supported_for( + **self._supported_filters_for(self.extinction), + ) + supported_tags = [klass.type_info.tag for klass in supported] + if new_type not in supported_tags: + msg = ( + f"Unsupported extinction type '{new_type}'. " + f'Supported: {supported_tags}. ' + f"For more information, use 'extinction.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + + old_extinction = self._extinction + self._extinction = ExtinctionFactory.create(new_type) + old_extinction._parent = None + self._extinction._parent = self + self._extinction._type.value = new_type + if announce: + console.paragraph('Extinction type changed to') + console.print(new_type) @property def name(self) -> str: @@ -118,24 +272,17 @@ def _restore_switchable_types(self, block: object) -> None: Called by the factory immediately after the experiment object is created and before any category parameters are loaded from CIF. Subclasses with switchable categories must override this method - and call their ``_set_`` private setter for each category - whose active implementation is identified by a CIF type tag. + and call their private swap hook for each category whose active + implementation is identified by a CIF type tag. Parameters ---------- block : object Parsed ``gemmi.cif.Block`` to read type tags from. """ - calculator_type = read_cif_str(block, '_calculation.calculator_type') - if calculator_type is not None: - self._set_calculator_type(calculator_type, announce=False) - - def _normalize_switchable_type_descriptors(self) -> None: - """ - Normalize switchable category descriptors after CIF loading. - """ - if self._calculator_type is not None: - self.calculation.calculator_type.value = self._calculator_type + calculator_tag = read_cif_str(block, '_calculator.type') + if calculator_tag is not None: + self._swap_calculator(calculator_tag, announce=False, strict=False) @property def as_cif(self) -> str: @@ -166,20 +313,20 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: raise NotImplementedError # ------------------------------------------------------------------ - # Calculation (switchable-category pattern) + # Calculator (switchable-category pattern) # ------------------------------------------------------------------ @property - def calculation(self) -> object: + def calculator(self) -> object: """ - The active calculation category for this experiment. + The active calculator category for this experiment. Holds the selected calculator type and provides access to the live calculator backend instance. """ if self._calculator is None: - self._resolve_calculation() - return self._calculation + self._resolve_calculator() + return self._calculator_category def _default_calculator_tag(self) -> str: """Return the default calculator tag for this experiment.""" @@ -189,36 +336,7 @@ def _default_calculator_tag(self) -> str: scattering_type=self.type.scattering_type.value, ) - def _set_calculator_type( - self, - tag: str, - *, - announce: bool = True, - ) -> None: - """Switch to a different calculator backend.""" - from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 - - supported = self._supported_calculator_tags() - if tag not in supported: - log.warning( - f"Unsupported calculator '{tag}' for experiment " - f"'{self.name}'. Supported: {supported}. " - f"For more information, use 'calculation.show_calculator_types()'", - ) - return - if self._calculator_type == tag and self._calculator is not None: - if announce: - console.paragraph(f"Calculator for experiment '{self.name}' already set to") - console.print(tag) - return - self._calculator = CalculatorFactory.create(tag) - self._calculator_type = tag - self.calculation.calculator_type.value = tag - if announce: - console.paragraph(f"Calculator for experiment '{self.name}' changed to") - console.print(tag) - - def _resolve_calculation(self) -> None: + def _resolve_calculator(self) -> None: """Auto-resolve the default calculator from category support.""" from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 @@ -227,8 +345,7 @@ def _resolve_calculation(self) -> None: if supported and tag not in supported: tag = supported[0] self._calculator = CalculatorFactory.create(tag) - self._calculator_type = tag - self.calculation.calculator_type.value = tag + self._calculator_category._type.value = tag def _supported_calculator_tags(self) -> list[str]: """ @@ -270,8 +387,7 @@ def __init__( ) -> None: super().__init__(name=name, type=type) - self._extinction_type: str = ExtinctionFactory.default_tag() - self._extinction = ExtinctionFactory.create(self._extinction_type) + self._extinction = ExtinctionFactory.create(ExtinctionFactory.default_tag()) self._linked_crystal_type: str = LinkedCrystalFactory.default_tag() self._linked_crystal = LinkedCrystalFactory.create(self._linked_crystal_type) self._instrument_type: str = InstrumentFactory.default_tag( @@ -286,7 +402,8 @@ def __init__( scattering_type=self.type.scattering_type.value, ) self._refln = ReflnFactory.create(self._refln_type) - self._resolve_calculation() + self._resolve_calculator() + self._attach_category_parents() @abstractmethod def _load_ascii_data_to_experiment(self, data_path: str) -> None: @@ -309,56 +426,14 @@ def extinction(self) -> object: """Active extinction correction model.""" return self._extinction - @property - def extinction_type(self) -> str: - """Tag of the active extinction correction model.""" - return self._extinction_type - - @extinction_type.setter - def extinction_type(self, new_type: str) -> None: + def _restore_switchable_types(self, block: object) -> None: """ - Switch to a different extinction correction model. - - Parameters - ---------- - new_type : str - Extinction tag (e.g. ``'becker-coppens'``). + Restore single-crystal switchable category types from CIF. """ - supported = ExtinctionFactory.supported_for( - calculator=self.calculation.calculator_type.value, - ) - supported_tags = [k.type_info.tag for k in supported] - if new_type not in supported_tags: - log.warning( - f"Unsupported extinction type '{new_type}'. " - f'Supported: {supported_tags}. ' - f"For more information, use 'show_extinction_types()'", - ) - return - self._extinction = ExtinctionFactory.create(new_type) - self._extinction_type = new_type - console.paragraph('Extinction type changed to') - console.print(new_type) - - def show_extinction_types(self) -> None: - """Print supported extinction types and mark current type.""" - supported = ExtinctionFactory.supported_for( - calculator=self.calculation.calculator_type.value, - ) - columns_data = [ - [ - '*' if klass.type_info.tag == self._extinction_type else '', - klass.type_info.tag, - klass.type_info.description, - ] - for klass in supported - ] - console.paragraph('Extinction types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) + super()._restore_switchable_types(block) + extinction_tag = read_cif_str(block, '_extinction.type') + if extinction_tag is not None: + self._replace_extinction(extinction_tag, announce=False, strict=False) # ------------------------------------------------------------------ # Linked crystal (read-only, single type) @@ -409,18 +484,20 @@ def __init__( self._linked_phases = LinkedPhasesFactory.create(self._linked_phases_type) self._excluded_regions_type: str = ExcludedRegionsFactory.default_tag() self._excluded_regions = ExcludedRegionsFactory.create(self._excluded_regions_type) - self._peak_profile_type: str = PeakFactory.default_tag( - scattering_type=self.type.scattering_type.value, - beam_mode=self.type.beam_mode.value, - ) self._data_type: str = DataFactory.default_tag( sample_form=self.type.sample_form.value, beam_mode=self.type.beam_mode.value, scattering_type=self.type.scattering_type.value, ) self._data = DataFactory.create(self._data_type) - self._peak = PeakFactory.create(self._peak_profile_type) - self._resolve_calculation() + self._peak = PeakFactory.create( + PeakFactory.default_tag( + scattering_type=self.type.scattering_type.value, + beam_mode=self.type.beam_mode.value, + ) + ) + self._resolve_calculator() + self._attach_category_parents() def _get_valid_linked_phases( self, @@ -511,28 +588,17 @@ def peak(self) -> object: """Peak category object with profile parameters and mixins.""" return self._peak - @property - def peak_profile_type(self) -> object: - """Currently selected peak profile type alias.""" - return PeakFactory._local_alias_for( - self._peak_profile_type, - **self._peak_profile_context(), - ) - - @peak_profile_type.setter - def peak_profile_type(self, new_type: str) -> None: - """ - Change the active peak profile type, if supported. - - Parameters - ---------- - new_type : str - New profile type as context-local alias or canonical tag. - """ + def _replace_peak_profile( + self, + new_type: str, + *, + announce: bool, + strict: bool = True, + ) -> None: + """Replace the active peak profile category.""" context = self._peak_profile_context() supported = PeakFactory.supported_for( - calculator=self.calculation.calculator_type.value, - **context, + **self._supported_filters_for(self.peak), ) supported_tags = [klass.type_info.tag for klass in supported] supported_aliases = [ @@ -541,48 +607,30 @@ def peak_profile_type(self, new_type: str) -> None: canonical_type = PeakFactory._canonical_tag_for(new_type, **context) if canonical_type not in supported_tags: - log.warning( + msg = ( f"Unsupported peak profile '{new_type}'. " f'Supported peak profiles: {supported_aliases}. ' - f"For more information, use 'show_peak_profile_types()'", + f"For more information, use 'peak.show_supported()'" ) + if strict: + raise ValueError(msg) + log.warning(msg) return - if self._peak is not None: + if self._peak is not None and announce: log.warning( 'Switching peak profile type discards existing peak parameters.', ) + old_peak = self._peak self._peak = PeakFactory.create(canonical_type) - self._peak_profile_type = canonical_type - console.paragraph(f"Peak profile type for experiment '{self.name}' changed to") - console.print(self.peak_profile_type) - - def show_peak_profile_types(self) -> None: - """Print supported peak profile types and mark current type.""" - supported = PeakFactory.supported_for( - calculator=self.calculation.calculator_type.value, - scattering_type=self.type.scattering_type.value, - beam_mode=self.type.beam_mode.value, - ) - context = self._peak_profile_context() - current = PeakFactory._local_alias_for(self._peak_profile_type, **context) - columns_data = [ - [ - '*' - if PeakFactory._local_alias_for(klass.type_info.tag, **context) == current - else '', - PeakFactory._local_alias_for(klass.type_info.tag, **context), - klass.type_info.description, - ] - for klass in supported - ] - console.paragraph('Peak profile types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) + if old_peak is not None: + old_peak._parent = None + self._peak._parent = self + self._peak._type.value = canonical_type + if announce: + console.paragraph(f"Peak profile type for experiment '{self.name}' changed to") + console.print(PeakFactory._local_alias_for(canonical_type, **context)) def _set_peak_profile_type(self, new_type: str) -> None: """ @@ -597,23 +645,7 @@ def _set_peak_profile_type(self, new_type: str) -> None: new_type : str Peak profile type alias or canonical tag. """ - context = self._peak_profile_context() - supported = PeakFactory.supported_for( - **context, - ) - supported_tags = [klass.type_info.tag for klass in supported] - canonical_type = PeakFactory._canonical_tag_for(new_type, **context) - if canonical_type not in supported_tags: - supported_aliases = [ - PeakFactory._local_alias_for(tag, **context) for tag in supported_tags - ] - log.warning( - f"Unsupported peak profile '{new_type}' in CIF. " - f'Supported: {supported_aliases}. Keeping default.', - ) - return - self._peak = PeakFactory.create(canonical_type) - self._peak_profile_type = canonical_type + self._replace_peak_profile(new_type, announce=False, strict=False) def _peak_profile_context(self) -> dict[str, object]: """ @@ -624,19 +656,12 @@ def _peak_profile_context(self) -> dict[str, object]: 'beam_mode': self.type.beam_mode.value, } - def _normalize_switchable_type_descriptors(self) -> None: - """ - Normalize switchable category descriptors after CIF loading. - """ - super()._normalize_switchable_type_descriptors() - self.peak.profile_type.value = self._peak_profile_type - def _restore_switchable_types(self, block: object) -> None: """ Restore switchable category types for powder experiments. - Reads ``_peak.profile_type`` from the CIF block and switches to - the matching peak implementation before category parameters are + Reads ``_peak.type`` from the CIF block and switches to the + matching peak implementation before category parameters are loaded, ensuring profile-specific descriptors are present. Parameters @@ -645,6 +670,6 @@ def _restore_switchable_types(self, block: object) -> None: Parsed ``gemmi.cif.Block`` to read type tags from. """ super()._restore_switchable_types(block) - peak_type = read_cif_str(block, '_peak.profile_type') + peak_type = read_cif_str(block, '_peak.type') if peak_type is not None: self._set_peak_profile_type(peak_type) diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index 4ae7f79ec..f1fbb3372 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -19,9 +19,8 @@ from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory from easydiffraction.io.ascii import load_numeric_block -from easydiffraction.utils.logging import console +from easydiffraction.io.cif.parse import read_cif_str from easydiffraction.utils.logging import log -from easydiffraction.utils.utils import render_table if TYPE_CHECKING: from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType @@ -62,10 +61,10 @@ def __init__( sample_form=self.type.sample_form.value, ) self._instrument = InstrumentFactory.create(self._instrument_type) - self._background_type: str = BackgroundFactory.default_tag() - self._background = BackgroundFactory.create(self._background_type) + self._background = BackgroundFactory.create(BackgroundFactory.default_tag()) self._refln = None self._sync_refln_category() + self._attach_category_parents() def _refln_collection_tag(self) -> str: """ @@ -86,9 +85,9 @@ def _refln_collection_type(self) -> type[object]: def _sync_refln_category(self) -> None: """Create or remove ``refln`` for the active calculator.""" - calculator_type = self._calculator_type or self._default_calculator_tag() + calculator_tag = self.calculator.type refln_collection_type = self._refln_collection_type() - calculator = CalculatorEnum(calculator_type) + calculator = CalculatorEnum(calculator_tag) if refln_collection_type.calculator_support.supports(calculator): if not isinstance(self._refln, refln_collection_type): self._refln = ReflnFactory.create(self._refln_collection_tag()) @@ -96,14 +95,15 @@ def _sync_refln_category(self) -> None: self._refln = None - def _set_calculator_type( + def _swap_calculator( self, tag: str, *, announce: bool = True, + strict: bool = True, ) -> None: """Switch calculator backend and sync ``refln`` availability.""" - super()._set_calculator_type(tag, announce=announce) + super()._swap_calculator(tag, announce=announce, strict=strict) self._sync_refln_category() def _load_ascii_data_to_experiment( @@ -181,63 +181,16 @@ def refln(self) -> object | None: # Background (switchable-category pattern) # ------------------------------------------------------------------ - @property - def background_type(self) -> object: - """Current background type enum value.""" - return self._background_type - - @background_type.setter - def background_type(self, new_type: str) -> None: - """Set a new background type and recreate background object.""" - if self._background_type == new_type: - console.paragraph(f"Background type for experiment '{self.name}' already set to") - console.print(new_type) - return - - supported = BackgroundFactory.supported_for( - calculator=self.calculation.calculator_type.value, - ) - supported_tags = [k.type_info.tag for k in supported] - if new_type not in supported_tags: - log.warning( - f"Unsupported background type '{new_type}'. " - f'Supported: {supported_tags}. ' - f"For more information, use 'show_background_types()'", - ) - return - - if len(self._background) > 0: - log.warning( - f'Switching background type discards {len(self._background)} ' - f'existing background point(s).', - ) - - self._background = BackgroundFactory.create(new_type) - self._background_type = new_type - console.paragraph(f"Background type for experiment '{self.name}' changed to") - console.print(new_type) - @property def background(self) -> object: """Active background model for this experiment.""" return self._background - def show_background_types(self) -> None: - """Print supported background types and mark current type.""" - supported = BackgroundFactory.supported_for( - calculator=self.calculation.calculator_type.value, - ) - columns_data = [ - [ - '*' if klass.type_info.tag == self._background_type else '', - klass.type_info.tag, - klass.type_info.description, - ] - for klass in supported - ] - console.paragraph('Background types') - render_table( - columns_headers=['', 'Type', 'Description'], - columns_alignment=['left', 'left', 'left'], - columns_data=columns_data, - ) + def _restore_switchable_types(self, block: object) -> None: + """ + Restore Bragg powder switchable category types from CIF. + """ + super()._restore_switchable_types(block) + background_tag = read_cif_str(block, '_background.type') + if background_tag is not None: + self._replace_background(background_tag, announce=False, strict=False) diff --git a/src/easydiffraction/datablocks/experiment/item/factory.py b/src/easydiffraction/datablocks/experiment/item/factory.py index 11e510e34..d722d5729 100644 --- a/src/easydiffraction/datablocks/experiment/item/factory.py +++ b/src/easydiffraction/datablocks/experiment/item/factory.py @@ -127,8 +127,6 @@ def _from_gemmi_block( for category in expt_obj.categories: category.from_cif(block) - expt_obj._normalize_switchable_type_descriptors() - return expt_obj # ------------------------------------------------------------------ diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 3bfbd5ec5..cae993fd0 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -2659,16 +2659,12 @@ def _cached_posterior_pair_surface( analysis = self._project.analysis sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) pair_caches = sidecar_data.get('pair_caches', {}) - for cache in analysis.bayesian_pair_caches: - cache_x = cache.param_unique_name_x.value - cache_y = cache.param_unique_name_y.value + for cache_data in pair_caches.values(): + cache_x = str(cache_data.get('param_unique_name_x', '')) + cache_y = str(cache_data.get('param_unique_name_y', '')) if {cache_x, cache_y} != {x_parameter_name, y_parameter_name}: continue - cache_data = pair_caches.get(cache.id.value) - if cache_data is None: - return None - x_grid = np.asarray(cache_data.get('x'), dtype=float) y_grid = np.asarray(cache_data.get('y'), dtype=float) density = np.asarray(cache_data.get('density'), dtype=float) diff --git a/src/easydiffraction/io/cif/parse.py b/src/easydiffraction/io/cif/parse.py index 9b1256936..089321e7c 100644 --- a/src/easydiffraction/io/cif/parse.py +++ b/src/easydiffraction/io/cif/parse.py @@ -43,7 +43,7 @@ def read_cif_str(block: gemmi.cif.Block, tag: str) -> str | None: block : gemmi.cif.Block Parsed CIF data block to read from. tag : str - CIF tag to look up (e.g. ``'_peak.profile_type'``). + CIF tag to look up (e.g. ``'_peak.type'``). Returns ------- diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py index ef9fa277d..929b2217c 100644 --- a/src/easydiffraction/io/cif/serialize.py +++ b/src/easydiffraction/io/cif/serialize.py @@ -241,15 +241,17 @@ def category_collection_to_cif( str CIF text representing the collection as a loop. """ - if not len(collection): - return '' - # Allow collections to conditionally suppress CIF output skip = getattr(collection, '_skip_cif_serialization', None) if skip is not None and skip(): return '' lines: list[str] = [] + scalar_descriptors = getattr(collection, 'scalar_descriptors', []) + lines.extend(param_to_cif(p) for p in scalar_descriptors) + + if not len(collection): + return '\n'.join(lines) # Header — use first item's CIF tag names as the canonical columns first_item = next(iter(collection.values())) @@ -398,9 +400,12 @@ def project_config_to_cif(project: object) -> str: return category_owner_to_cif(config) lines: list[str] = [_as_cif_text(project.info)] - rendering = getattr(project, 'rendering', None) - if rendering is not None: - lines.extend(('', _as_cif_text(rendering))) + chart = getattr(project, 'chart', None) + if chart is not None: + lines.extend(('', _as_cif_text(chart))) + table = getattr(project, 'table', None) + if table is not None: + lines.extend(('', _as_cif_text(table))) return '\n'.join(lines) @@ -427,32 +432,7 @@ def experiment_to_cif(experiment: object) -> str: def analysis_to_cif(analysis: object) -> str: """Render analysis metadata, aliases, and constraints to CIF.""" - parts: list[str] = [f'_fitting.mode_type {format_value(analysis.fitting_mode_type)}'] - - body = category_owner_to_cif(analysis) - if not body: - fallback_sections = [ - getattr(analysis, 'fitting', None), - getattr(analysis, 'aliases', None), - getattr(analysis, 'constraints', None), - ] - - if analysis.fitting_mode_type == 'joint': - fallback_sections.append(getattr(analysis, 'joint_fit', None)) - elif analysis.fitting_mode_type == 'sequential': - fallback_sections.extend([ - getattr(analysis, 'sequential_fit', None), - getattr(analysis, 'sequential_fit_extract', None), - ]) - - body = '\n\n'.join([ - _as_cif_text(section) for section in fallback_sections if section is not None - ]) - - if body: - parts.append(body) - - return '\n\n'.join(parts) + return category_owner_to_cif(analysis) def summary_to_cif(_summary: object) -> str: @@ -537,9 +517,13 @@ def project_config_from_cif(project: object, cif_text: str) -> None: _populate_project_info_from_block(project.info, block) - rendering = getattr(project, 'rendering', None) - if rendering is not None: - rendering.from_cif(block) + chart = getattr(project, 'chart', None) + if chart is not None: + chart.from_cif(block) + + table = getattr(project, 'table', None) + if table is not None: + table.from_cif(block) verbosity = getattr(project, 'verbosity', None) if verbosity is not None: @@ -567,9 +551,8 @@ def analysis_from_cif(analysis: object, cif_text: str) -> None: _raise_for_legacy_analysis_tags(block) analysis._set_fitting_mode_type(_analysis_mode_from_cif_block(block)) - - # Restore fit configuration - analysis.fitting.from_cif(block) + analysis._set_minimizer_type(_analysis_minimizer_from_cif_block(block)) + analysis.minimizer.from_cif(block) _restore_mode_specific_analysis_sections(analysis, block) # Restore aliases (loop) @@ -588,18 +571,12 @@ def _has_persisted_fit_state_sections(block: object) -> bool: """Return True when any persisted fit-state section is present.""" scalar_tags = ( '_fit_result.result_kind', - '_deterministic_result.optimizer_name', - '_bayesian_result.sampler_name', - '_bayesian_sampler.steps', - '_bayesian_convergence.converged', + '_minimizer.runtime_seconds', + '_minimizer.best_log_posterior', ) loop_tags = ( '_fit_parameter.param_unique_name', '_fit_parameter_correlation.param_unique_name_i', - '_bayesian_parameter_posterior.unique_name', - '_bayesian_distribution_cache.param_unique_name', - '_bayesian_pair_cache.param_unique_name_x', - '_bayesian_predictive_dataset.experiment_name', ) return any(_has_cif_value(block, tag) for tag in scalar_tags) or any( @@ -614,23 +591,6 @@ def _restore_common_fit_state(analysis: object, block: object) -> None: analysis.fit_parameter_correlations.from_cif(block) -def _restore_deterministic_fit_state(analysis: object, block: object) -> None: - """Restore deterministic-only persisted fit-state categories.""" - analysis.deterministic_result.from_cif(block) - - -def _restore_bayesian_fit_state(analysis: object, block: object) -> None: - """Restore Bayesian-only persisted fit-state categories.""" - analysis.bayesian_result.from_cif(block) - analysis.bayesian_sampler.from_cif(block) - analysis.bayesian_convergence.from_cif(block) - analysis.bayesian_parameter_posteriors.from_cif(block) - analysis.bayesian_distribution_caches.from_cif(block) - analysis.bayesian_pair_caches.from_cif(block) - analysis.bayesian_predictive_datasets.from_cif(block) - analysis._sync_live_minimizer_from_persisted_fit_state() - - def _restore_persisted_fit_state(analysis: object, block: object) -> None: """ Restore persisted fit-state categories after analysis configuration. @@ -642,28 +602,17 @@ def _restore_persisted_fit_state(analysis: object, block: object) -> None: result_kind_value = analysis.fit_result.result_kind.value try: - result_kind = FitResultKindEnum(result_kind_value) + FitResultKindEnum(result_kind_value) except ValueError: log.warning( 'Unsupported _fit_result.result_kind in analysis CIF: ' f'{result_kind_value!r}. Skipping kind-specific fit-state categories.', ) - return - - if result_kind is FitResultKindEnum.DETERMINISTIC: - _restore_deterministic_fit_state(analysis, block) - return - - _restore_bayesian_fit_state(analysis, block) def _collect_legacy_analysis_tags(block: object) -> list[str]: """Return deprecated analysis CIF tags present in a block.""" legacy_tags: list[str] = [] - if _has_cif_value(block, '_fit.minimizer_type'): - legacy_tags.append('_fit.minimizer_type') - if _has_cif_value(block, '_fit.mode'): - legacy_tags.append('_fit.mode') if _has_cif_loop(block, '_joint_fit_experiment.id'): legacy_tags.append('_joint_fit_experiment.id') if _has_cif_loop(block, '_joint_fit_experiment.weight'): @@ -679,8 +628,8 @@ def _raise_for_legacy_analysis_tags(block: object) -> None: msg = ( 'Legacy analysis CIF tags are no longer supported: ' - f'{legacy_tags}. Use _fitting.minimizer_type, _fitting.mode_type, ' - '_joint_fit.experiment_id, and _joint_fit.weight.' + f'{legacy_tags}. Use _minimizer.type, _fitting_mode.type, ' + '_minimizer.*, _joint_fit.experiment_id, and _joint_fit.weight.' ) raise ValueError(msg) @@ -688,7 +637,7 @@ def _raise_for_legacy_analysis_tags(block: object) -> None: def _analysis_mode_from_cif_block(block: object) -> str: """Return the fitting mode stored in an analysis CIF block.""" read_cif_string = _make_cif_string_reader(block) - mode_value = read_cif_string('_fitting.mode_type') + mode_value = read_cif_string('_fitting_mode.type') if mode_value is not None: return mode_value @@ -697,6 +646,18 @@ def _analysis_mode_from_cif_block(block: object) -> str: return FitModeEnum.default().value +def _analysis_minimizer_from_cif_block(block: object) -> str: + """Return the minimizer type stored in an analysis CIF block.""" + read_cif_string = _make_cif_string_reader(block) + minimizer_value = read_cif_string('_minimizer.type') + if minimizer_value is not None: + return minimizer_value + + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum # noqa: PLC0415 + + return MinimizerTypeEnum.default().value + + def _has_joint_fit_rows(block: object) -> bool: """Return True when joint-fit rows are present.""" return _has_cif_loop(block, '_joint_fit.experiment_id') or _has_cif_loop( @@ -732,7 +693,7 @@ def _warn_inactive_analysis_sections( if has_sequential_settings or has_sequential_extract_rows: skipped_sections.append('sequential_fit') log.warning( - 'Skipping inactive analysis CIF sections while fitting_mode_type is single: ' + 'Skipping inactive analysis CIF sections while fitting_mode is single: ' f'{skipped_sections}.' ) @@ -743,12 +704,12 @@ def _restore_mode_specific_analysis_sections(analysis: object, block: object) -> has_sequential_settings = _has_sequential_fit_settings(block) has_sequential_extract_rows = _has_cif_loop(block, '_sequential_fit_extract.id') - if analysis.fitting_mode_type == 'joint': + if analysis.fitting_mode.type == 'joint': if has_joint_rows: analysis.joint_fit.from_cif(block) return - if analysis.fitting_mode_type == 'sequential': + if analysis.fitting_mode.type == 'sequential': if has_sequential_settings: analysis.sequential_fit.from_cif(block) if has_sequential_extract_rows: @@ -841,8 +802,9 @@ def param_from_cif( found_values = candidates break - # If no values found, the parameter keeps its default value. + # If no values found, use the descriptor default when available. if not found_values: + _set_param_to_default_from_cif(self, raw=None) return # If found, pick the one at the given index. @@ -850,6 +812,33 @@ def param_from_cif( _set_param_from_raw_cif_value(self, raw) +def _set_param_to_default_from_cif( + param: GenericDescriptorBase, + *, + raw: str | None, +) -> None: + """ + Resolve missing or unknown CIF values to descriptor defaults. + + Parameters + ---------- + param : GenericDescriptorBase + Descriptor being populated from CIF. + raw : str | None + Raw CIF token, or ``None`` when no tag was present. + """ + value_spec = getattr(param, '_value_spec', None) + if value_spec is not None and (value_spec.has_default or value_spec.allow_none): + param.value = value_spec.default_value() + return + + detail = 'missing tag' if raw is None else f'value {raw!r}' + log.error( + f"Cannot load required CIF field '{param.unique_name}': {detail}.", + exc_type=ValueError, + ) + + def category_item_from_cif( self: CategoryItem, block: gemmi.cif.Block, @@ -879,8 +868,9 @@ def _set_param_from_raw_cif_value( """ raw = _strip_cif_text_field_delimiters(raw) - # CIF unknown / inapplicable markers → keep default + # CIF unknown / inapplicable markers → descriptor default if raw in {'?', '.'}: + _set_param_to_default_from_cif(param, raw=raw) return if param._value_type == DataTypes.INTEGER: @@ -966,6 +956,9 @@ def category_collection_from_cif( msg = 'Child class is not defined.' raise ValueError(msg) + for param in self.scalar_descriptors: + param.from_cif(block) + # Create a temporary instance to access its parameters and # parameter CIF names category_item = self._item_type() @@ -991,13 +984,17 @@ def category_collection_from_cif( for row_idx in range(num_rows): current_item = self._items[row_idx] for param in current_item.parameters: + tag_found = False for cif_name in param._cif_handler.names: if cif_name in loop.tags: col_idx = loop.tags.index(cif_name) # TODO: The following is duplication of # param_from_cif _set_param_from_raw_cif_value(param, array[row_idx][col_idx]) + tag_found = True break + if not tag_found: + _set_param_to_default_from_cif(param, raw=None) after_from_cif = getattr(self, '_after_from_cif', None) if callable(after_from_cif): diff --git a/src/easydiffraction/io/results_sidecar.py b/src/easydiffraction/io/results_sidecar.py index 36d442c77..db7ed17cf 100644 --- a/src/easydiffraction/io/results_sidecar.py +++ b/src/easydiffraction/io/results_sidecar.py @@ -4,19 +4,25 @@ from __future__ import annotations -from pathlib import Path -from tempfile import NamedTemporaryFile +from typing import TYPE_CHECKING import numpy as np +from easydiffraction.analysis.enums import FitResultKindEnum from easydiffraction.utils.logging import log -_DEFAULT_SIDECAR_FILE_NAME = 'results.h5' +if TYPE_CHECKING: + from pathlib import Path + +SidecarPayload = dict[str, dict[str, object]] +SIDECAR_FILE_NAME = 'results.h5' _POSTERIOR_PARAMETER_SAMPLES_PATH = '/posterior/parameter_samples' _POSTERIOR_LOG_POSTERIOR_PATH = '/posterior/log_posterior' _POSTERIOR_DRAW_INDEX_PATH = '/posterior/draw_index' +_DISTRIBUTION_CACHE_GROUP = '/distribution_cache' +_PAIR_CACHE_GROUP = '/pair_cache' +_PREDICTIVE_GROUP = '/predictive' _POSTERIOR_SAMPLE_NDIM = 3 -_PREDICTIVE_DRAWS_NDIM = 2 def _normalized_hdf5_path(path: str) -> str: @@ -24,43 +30,9 @@ def _normalized_hdf5_path(path: str) -> str: return path.lstrip('/') -def _sidecar_file_name(analysis: object) -> str: - """Return the configured sidecar file name for an analysis.""" - bayesian_result = getattr(analysis, 'bayesian_result', None) - if bayesian_result is None: - return _DEFAULT_SIDECAR_FILE_NAME - - file_name = bayesian_result.sidecar_file.value - if not isinstance(file_name, str) or not file_name.strip(): - return _DEFAULT_SIDECAR_FILE_NAME - - normalized_name = file_name.strip() - normalized_path = Path(normalized_name) - if ( - normalized_path.is_absolute() - or normalized_path.name in {'', '.', '..'} - or normalized_path.name != normalized_name - ): - log.warning( - 'Ignoring Bayesian sidecar file path outside the analysis directory: ' - f'{normalized_name!r}. Using {_DEFAULT_SIDECAR_FILE_NAME!r} instead.' - ) - return _DEFAULT_SIDECAR_FILE_NAME - - return normalized_path.name - - -def _sidecar_path(*, analysis: object, analysis_dir: Path) -> Path: +def _sidecar_path(*, analysis_dir: Path) -> Path: """Return the results sidecar path inside the analysis directory.""" - resolved_analysis_dir = analysis_dir.resolve() - sidecar_path = (resolved_analysis_dir / _sidecar_file_name(analysis)).resolve() - if sidecar_path.parent != resolved_analysis_dir: - log.warning( - 'Resolved Bayesian sidecar file path escaped the analysis directory. ' - f'Using {_DEFAULT_SIDECAR_FILE_NAME!r} instead.' - ) - return resolved_analysis_dir / _DEFAULT_SIDECAR_FILE_NAME - return sidecar_path + return analysis_dir.resolve() / SIDECAR_FILE_NAME def _should_use_sidecar(analysis: object) -> bool: @@ -71,15 +43,7 @@ def _should_use_sidecar(analysis: object) -> bool: if not callable(has_fit_state) or not has_fit_state(): return False - if analysis.fit_result.result_kind.value != 'bayesian': - return False - - return any(( - analysis.bayesian_result.has_posterior_samples.value, - len(analysis.bayesian_distribution_caches) > 0, - len(analysis.bayesian_pair_caches) > 0, - len(analysis.bayesian_predictive_datasets) > 0, - )) + return analysis.fit_result.result_kind.value == FitResultKindEnum.BAYESIAN.value def _delete_stale_sidecar(sidecar_path: Path) -> None: @@ -90,6 +54,18 @@ def _delete_stale_sidecar(sidecar_path: Path) -> None: sidecar_path.unlink() +def warn_analysis_results_sidecar_overwrite(*, analysis_dir: Path) -> None: + """Warn when a new fit will overwrite existing sidecar arrays.""" + sidecar_path = _sidecar_path(analysis_dir=analysis_dir) + if not sidecar_path.is_file() or sidecar_path.stat().st_size == 0: + return + + log.warning( + f"Existing fit results sidecar '{sidecar_path}' will be overwritten " + 'when the new fit is saved.' + ) + + def _create_dataset(handle: object, path: str, data: np.ndarray) -> None: """Create or replace one dataset in an open HDF5 file.""" normalized_path = _normalized_hdf5_path(path) @@ -131,30 +107,41 @@ def _posterior_payload_from_analysis(analysis: object) -> dict[str, np.ndarray | return dict(sidecar_data.get('posterior', {})) -def _distribution_cache_payload(analysis: object) -> dict[str, dict[str, np.ndarray]]: - """Return persisted distribution caches keyed by parameter name.""" +def _distribution_cache_payload(analysis: object) -> SidecarPayload: + """Return distribution caches keyed by parameter name.""" + fit_results = getattr(analysis, 'fit_results', None) + distribution_caches = getattr(fit_results, 'posterior_distribution_caches', None) + if distribution_caches: + return dict(distribution_caches) + sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) return dict(sidecar_data.get('distribution_caches', {})) -def _pair_cache_payload(analysis: object) -> dict[str, dict[str, np.ndarray]]: - """Return persisted pair-cache arrays keyed by cache id.""" +def _pair_cache_payload(analysis: object) -> SidecarPayload: + """Return pair-cache arrays keyed by cache id.""" + fit_results = getattr(analysis, 'fit_results', None) + pair_caches = getattr(fit_results, 'posterior_pair_caches', None) + if pair_caches: + return dict(pair_caches) + sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) return dict(sidecar_data.get('pair_caches', {})) -def _predictive_payload(analysis: object) -> dict[str, dict[str, np.ndarray]]: +def _predictive_payload(analysis: object) -> SidecarPayload: """Return persisted predictive arrays keyed by experiment name.""" fit_results = getattr(analysis, 'fit_results', None) posterior_predictive = getattr(fit_results, 'posterior_predictive', None) if posterior_predictive: - payload: dict[str, dict[str, np.ndarray]] = {} + payload: SidecarPayload = {} for runtime_key, summary in posterior_predictive.items(): experiment_name = getattr(summary, 'experiment_name', None) if not isinstance(experiment_name, str) or not experiment_name.strip(): experiment_name = runtime_key dataset_payload = payload.setdefault(experiment_name, {}) + dataset_payload['x_axis_name'] = str(summary.x_axis_name) dataset_payload['x'] = np.asarray(summary.x, dtype=float) dataset_payload['best_sample_prediction'] = np.asarray( summary.best_sample_prediction, @@ -177,14 +164,11 @@ def _predictive_payload(analysis: object) -> dict[str, dict[str, np.ndarray]]: def _validate_posterior_payload( - analysis: object, payload: dict[str, np.ndarray | None], ) -> bool: """Return whether posterior arrays match stored metadata.""" parameter_samples = payload.get('parameter_samples') if parameter_samples is None: - if analysis.bayesian_result.has_posterior_samples.value: - log.warning('Bayesian fit-state expects posterior samples, but none are available.') return False parameter_samples = np.asarray(parameter_samples, dtype=float) @@ -194,15 +178,7 @@ def _validate_posterior_payload( ) return False - n_draws, n_chains, n_parameters = parameter_samples.shape - if not _posterior_manifest_counts_match( - analysis, - n_draws=n_draws, - n_chains=n_chains, - n_parameters=n_parameters, - ): - return False - + n_draws, n_chains, _ = parameter_samples.shape return _posterior_aux_shapes_match( payload, n_draws=n_draws, @@ -210,29 +186,6 @@ def _validate_posterior_payload( ) -def _posterior_manifest_counts_match( - analysis: object, - *, - n_draws: int, - n_chains: int, - n_parameters: int, -) -> bool: - """Return whether manifest counts match the sample shape.""" - if analysis.bayesian_convergence.n_draws.value not in {0, n_draws}: - log.warning('Posterior sample draw count does not match bayesian_convergence.n_draws.') - return False - if analysis.bayesian_convergence.n_chains.value not in {0, n_chains}: - log.warning('Posterior sample chain count does not match bayesian_convergence.n_chains.') - return False - if analysis.bayesian_convergence.n_parameters.value not in {0, n_parameters}: - log.warning( - 'Posterior sample parameter count does not match bayesian_convergence.n_parameters.' - ) - return False - - return True - - def _posterior_aux_shapes_match( payload: dict[str, np.ndarray | None], *, @@ -258,7 +211,7 @@ def _posterior_aux_shapes_match( def _write_posterior_payload(handle: object, analysis: object) -> bool: """Write canonical posterior arrays when they are available.""" payload = _posterior_payload_from_analysis(analysis) - if not _validate_posterior_payload(analysis, payload): + if not _validate_posterior_payload(payload): return False parameter_samples = np.asarray(payload['parameter_samples'], dtype=float) @@ -275,134 +228,51 @@ def _write_posterior_payload(handle: object, analysis: object) -> bool: return True -def _write_distribution_caches(handle: object, analysis: object) -> bool: - """Write cached posterior distribution arrays for manifest rows.""" - payload = _distribution_cache_payload(analysis) +def _write_payload_group( + handle: object, + group_name: str, + payload: SidecarPayload, +) -> bool: + """Write a mapping payload under one HDF5 group.""" wrote_any = False - for cache in analysis.bayesian_distribution_caches: - cache_data = payload.get(cache.param_unique_name.value) - if cache_data is None: - continue - - x_values = np.asarray(cache_data.get('x')) - density_values = np.asarray(cache_data.get('density')) - n_grid = int(cache.n_grid.value) - if x_values.shape != (n_grid,) or density_values.shape != (n_grid,): - log.warning( - 'Skipping Bayesian distribution cache with shape mismatch for ' - f'{cache.param_unique_name.value!r}.' - ) - continue + root = handle.require_group(_normalized_hdf5_path(group_name)) + for item_id, item_payload in payload.items(): + base_group_name = str(item_id).strip('/').replace('/', '_') or 'item' + item_group_name = base_group_name + suffix = 2 + while item_group_name in root: + item_group_name = f'{base_group_name}_{suffix}' + suffix += 1 + item_group = root.create_group(item_group_name) + item_group.attrs['id'] = str(item_id) + for dataset_name, values in item_payload.items(): + if values is None: + continue + if isinstance(values, str): + item_group.attrs[dataset_name] = values + wrote_any = True + continue + item_group.create_dataset(dataset_name, data=np.asarray(values)) + wrote_any = True + return wrote_any - _create_dataset(handle, cache.x_path.value, x_values) - _create_dataset(handle, cache.density_path.value, density_values) - wrote_any = True - return wrote_any +def _write_distribution_caches(handle: object, analysis: object) -> bool: + """Write cached posterior distribution arrays.""" + payload = _distribution_cache_payload(analysis) + return _write_payload_group(handle, _DISTRIBUTION_CACHE_GROUP, payload) def _write_pair_caches(handle: object, analysis: object) -> bool: - """Write cached posterior pair-density arrays for manifest rows.""" + """Write cached posterior pair-density arrays.""" payload = _pair_cache_payload(analysis) - wrote_any = False - for cache in analysis.bayesian_pair_caches: - cache_data = payload.get(cache.id.value) - if cache_data is None: - continue - - x_values = np.asarray(cache_data.get('x')) - y_values = np.asarray(cache_data.get('y')) - density_values = np.asarray(cache_data.get('density')) - contour_levels = np.asarray(cache_data.get('contour_levels')) - n_grid_x = int(cache.n_grid_x.value) - n_grid_y = int(cache.n_grid_y.value) - - valid_density_shape = density_values.shape in { - (n_grid_y, n_grid_x), - (n_grid_x, n_grid_y), - } - if ( - x_values.shape != (n_grid_x,) - or y_values.shape != (n_grid_y,) - or not valid_density_shape - ): - log.warning( - f'Skipping Bayesian pair cache with shape mismatch for {cache.id.value!r}.' - ) - continue - - _create_dataset(handle, cache.x_path.value, x_values) - _create_dataset(handle, cache.y_path.value, y_values) - _create_dataset(handle, cache.density_path.value, density_values) - _create_dataset(handle, cache.contour_level_path.value, contour_levels) - wrote_any = True - - return wrote_any + return _write_payload_group(handle, _PAIR_CACHE_GROUP, payload) def _write_predictive_datasets(handle: object, analysis: object) -> bool: - """Write cached posterior predictive arrays for manifest rows.""" + """Write cached posterior predictive arrays.""" payload = _predictive_payload(analysis) - wrote_any = False - for dataset in analysis.bayesian_predictive_datasets: - dataset_data = payload.get(dataset.experiment_name.value) - if dataset_data is None: - continue - - x_values = np.asarray(dataset_data.get('x')) - best_sample_prediction = np.asarray(dataset_data.get('best_sample_prediction')) - n_x = int(dataset.n_x.value) - if x_values.shape != (n_x,) or best_sample_prediction.shape != (n_x,): - log.warning( - 'Skipping Bayesian predictive dataset with shape mismatch for ' - f'{dataset.experiment_name.value!r}.' - ) - continue - - _create_dataset(handle, dataset.x_path.value, x_values) - _create_dataset( - handle, - dataset.best_sample_prediction_path.value, - best_sample_prediction, - ) - - for field_name, path_value in ( - ('lower_95', dataset.lower_95_path.value), - ('upper_95', dataset.upper_95_path.value), - ('lower_68', dataset.lower_68_path.value), - ('upper_68', dataset.upper_68_path.value), - ): - values = dataset_data.get(field_name) - if values is None or path_value is None: - continue - values_array = np.asarray(values) - if values_array.shape != (n_x,): - log.warning( - 'Skipping Bayesian predictive band with shape mismatch for ' - f'{dataset.experiment_name.value!r}:{field_name}.' - ) - continue - _create_dataset(handle, path_value, values_array) - - draws = dataset_data.get('draws') - if draws is not None and dataset.draws_path.value is not None: - draws_array = np.asarray(draws) - if draws_array.ndim != _PREDICTIVE_DRAWS_NDIM or draws_array.shape[1] != n_x: - log.warning( - 'Skipping Bayesian predictive draws with shape mismatch for ' - f'{dataset.experiment_name.value!r}.' - ) - elif dataset.n_draws_cached.value not in {0, draws_array.shape[0]}: - log.warning( - 'Skipping Bayesian predictive draws whose draw count does not match ' - 'the manifest metadata.' - ) - else: - _create_dataset(handle, dataset.draws_path.value, draws_array) - - wrote_any = True - - return wrote_any + return _write_payload_group(handle, _PREDICTIVE_GROUP, payload) def write_analysis_results_sidecar( @@ -420,14 +290,8 @@ def write_analysis_results_sidecar( results. analysis_dir : Path The project ``analysis/`` directory. - - Raises - ------ - Exception - Propagated when sidecar writing fails after temporary-file - cleanup. """ - sidecar_path = _sidecar_path(analysis=analysis, analysis_dir=analysis_dir) + sidecar_path = _sidecar_path(analysis_dir=analysis_dir) if not _should_use_sidecar(analysis): _delete_stale_sidecar(sidecar_path) return @@ -435,34 +299,17 @@ def write_analysis_results_sidecar( import h5py # noqa: PLC0415 analysis_dir.mkdir(parents=True, exist_ok=True) - with NamedTemporaryFile( - delete=False, - dir=analysis_dir, - prefix=f'{sidecar_path.stem}.', - suffix=sidecar_path.suffix, - ) as temporary_file: - temporary_path = Path(temporary_file.name) - - try: - with h5py.File(temporary_path, 'w') as handle: - wrote_any = _write_posterior_payload(handle, analysis) - wrote_any = _write_distribution_caches(handle, analysis) or wrote_any - wrote_any = _write_pair_caches(handle, analysis) or wrote_any - wrote_any = _write_predictive_datasets(handle, analysis) or wrote_any - except Exception: - if temporary_path.exists(): - temporary_path.unlink() - raise + with h5py.File(sidecar_path, 'w') as handle: + wrote_any = _write_posterior_payload(handle, analysis) + wrote_any = _write_distribution_caches(handle, analysis) or wrote_any + wrote_any = _write_pair_caches(handle, analysis) or wrote_any + wrote_any = _write_predictive_datasets(handle, analysis) or wrote_any if not wrote_any: - temporary_path.unlink() _delete_stale_sidecar(sidecar_path) - return - - temporary_path.replace(sidecar_path) -def _read_posterior_payload(handle: object, analysis: object) -> dict[str, np.ndarray]: +def _read_posterior_payload(handle: object) -> dict[str, np.ndarray]: """Read canonical posterior arrays from a sidecar file.""" parameter_samples = _read_dataset(handle, _POSTERIOR_PARAMETER_SAMPLES_PATH) if parameter_samples is None: @@ -478,127 +325,52 @@ def _read_posterior_payload(handle: object, analysis: object) -> dict[str, np.nd if draw_index is not None: payload['draw_index'] = np.asarray(draw_index) - if not _validate_posterior_payload(analysis, payload): + if not _validate_posterior_payload(payload): return {} return payload -def _read_distribution_caches( - handle: object, analysis: object -) -> dict[str, dict[str, np.ndarray]]: - """Read cached posterior distribution arrays for manifest rows.""" - payload: dict[str, dict[str, np.ndarray]] = {} - for cache in analysis.bayesian_distribution_caches: - x_values = _read_dataset(handle, cache.x_path.value) - density_values = _read_dataset(handle, cache.density_path.value) - if x_values is None or density_values is None: - continue - if x_values.shape != (int(cache.n_grid.value),) or density_values.shape != ( - int(cache.n_grid.value), - ): - log.warning( - 'Skipping restored Bayesian distribution cache with shape mismatch for ' - f'{cache.param_unique_name.value!r}.' - ) - continue - payload[cache.param_unique_name.value] = { - 'x': np.asarray(x_values), - 'density': np.asarray(density_values), - } - return payload +def _read_hdf5_attr(value: object) -> object: + """Return one HDF5 attribute as a plain Python value.""" + if isinstance(value, bytes): + return value.decode('utf-8') + return value -def _read_pair_caches(handle: object, analysis: object) -> dict[str, dict[str, np.ndarray]]: - """Read cached posterior pair-density arrays for manifest rows.""" - payload: dict[str, dict[str, np.ndarray]] = {} - for cache in analysis.bayesian_pair_caches: - x_values = _read_dataset(handle, cache.x_path.value) - y_values = _read_dataset(handle, cache.y_path.value) - density_values = _read_dataset(handle, cache.density_path.value) - contour_levels = _read_dataset(handle, cache.contour_level_path.value) - if any(value is None for value in (x_values, y_values, density_values, contour_levels)): - continue - - n_grid_x = int(cache.n_grid_x.value) - n_grid_y = int(cache.n_grid_y.value) - valid_density_shape = density_values.shape in { - (n_grid_y, n_grid_x), - (n_grid_x, n_grid_y), - } - if ( - x_values.shape != (n_grid_x,) - or y_values.shape != (n_grid_y,) - or not valid_density_shape - ): - log.warning( - 'Skipping restored Bayesian pair cache with shape mismatch for ' - f'{cache.id.value!r}.' - ) - continue +def _read_payload_group(handle: object, group_name: str) -> SidecarPayload: + """Read a mapping payload from one HDF5 group.""" + payload: SidecarPayload = {} + normalized_group = _normalized_hdf5_path(group_name) + if normalized_group not in handle: + return payload - payload[cache.id.value] = { - 'x': np.asarray(x_values), - 'y': np.asarray(y_values), - 'density': np.asarray(density_values), - 'contour_levels': np.asarray(contour_levels), + root = handle[normalized_group] + for item_name, item_group in root.items(): + item_id = str(item_group.attrs.get('id', item_name)) + item_payload: dict[str, object] = { + dataset_name: np.asarray(dataset) for dataset_name, dataset in item_group.items() } + for attr_name, attr_value in item_group.attrs.items(): + if attr_name == 'id': + continue + item_payload[attr_name] = _read_hdf5_attr(attr_value) + payload[item_id] = item_payload return payload -def _read_predictive_datasets( - handle: object, analysis: object -) -> dict[str, dict[str, np.ndarray]]: - """Read cached posterior predictive arrays for manifest rows.""" - payload: dict[str, dict[str, np.ndarray]] = {} - for dataset in analysis.bayesian_predictive_datasets: - x_values = _read_dataset(handle, dataset.x_path.value) - best_sample_prediction = _read_dataset(handle, dataset.best_sample_prediction_path.value) - if x_values is None or best_sample_prediction is None: - continue - - n_x = int(dataset.n_x.value) - if x_values.shape != (n_x,) or best_sample_prediction.shape != (n_x,): - log.warning( - 'Skipping restored Bayesian predictive dataset with shape mismatch for ' - f'{dataset.experiment_name.value!r}.' - ) - continue +def _read_distribution_caches(handle: object) -> SidecarPayload: + """Read cached posterior distribution arrays.""" + return _read_payload_group(handle, _DISTRIBUTION_CACHE_GROUP) - dataset_payload: dict[str, np.ndarray] = { - 'x': np.asarray(x_values), - 'best_sample_prediction': np.asarray(best_sample_prediction), - } - for field_name, path_value in ( - ('lower_95', dataset.lower_95_path.value), - ('upper_95', dataset.upper_95_path.value), - ('lower_68', dataset.lower_68_path.value), - ('upper_68', dataset.upper_68_path.value), - ('draws', dataset.draws_path.value), - ): - if path_value is None: - continue - values = _read_dataset(handle, path_value) - if values is None: - continue - values_array = np.asarray(values) - if field_name == 'draws': - if values_array.ndim != _PREDICTIVE_DRAWS_NDIM or values_array.shape[1] != n_x: - log.warning( - 'Skipping restored Bayesian predictive draws with shape mismatch for ' - f'{dataset.experiment_name.value!r}.' - ) - continue - elif values_array.shape != (n_x,): - log.warning( - 'Skipping restored Bayesian predictive band with shape mismatch for ' - f'{dataset.experiment_name.value!r}:{field_name}.' - ) - continue - dataset_payload[field_name] = values_array +def _read_pair_caches(handle: object) -> SidecarPayload: + """Read cached posterior pair-density arrays.""" + return _read_payload_group(handle, _PAIR_CACHE_GROUP) - payload[dataset.experiment_name.value] = dataset_payload - return payload + +def _read_predictive_datasets(handle: object) -> SidecarPayload: + """Read cached posterior predictive arrays.""" + return _read_payload_group(handle, _PREDICTIVE_GROUP) def read_analysis_results_sidecar( @@ -620,7 +392,7 @@ def read_analysis_results_sidecar( if not _should_use_sidecar(analysis): return - sidecar_path = _sidecar_path(analysis=analysis, analysis_dir=analysis_dir) + sidecar_path = _sidecar_path(analysis_dir=analysis_dir) if not sidecar_path.is_file(): log.warning( 'Expected Bayesian results sidecar is missing: ' @@ -633,19 +405,19 @@ def read_analysis_results_sidecar( with h5py.File(sidecar_path, 'r') as handle: sidecar_data: dict[str, object] = {} - posterior_payload = _read_posterior_payload(handle, analysis) + posterior_payload = _read_posterior_payload(handle) if posterior_payload: sidecar_data['posterior'] = posterior_payload - distribution_caches = _read_distribution_caches(handle, analysis) + distribution_caches = _read_distribution_caches(handle) if distribution_caches: sidecar_data['distribution_caches'] = distribution_caches - pair_caches = _read_pair_caches(handle, analysis) + pair_caches = _read_pair_caches(handle) if pair_caches: sidecar_data['pair_caches'] = pair_caches - predictive_datasets = _read_predictive_datasets(handle, analysis) + predictive_datasets = _read_predictive_datasets(handle) if predictive_datasets: sidecar_data['predictive_datasets'] = predictive_datasets diff --git a/src/easydiffraction/project/categories/chart/__init__.py b/src/easydiffraction/project/categories/chart/__init__.py new file mode 100644 index 000000000..1ae8bf874 --- /dev/null +++ b/src/easydiffraction/project/categories/chart/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project chart category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.chart.default import Chart +from easydiffraction.project.categories.chart.factory import ChartFactory diff --git a/src/easydiffraction/project/categories/chart/default.py b/src/easydiffraction/project/categories/chart/default.py new file mode 100644 index 000000000..5d45cdcea --- /dev/null +++ b/src/easydiffraction/project/categories/chart/default.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project chart category.""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.display.plotting import Plotter +from easydiffraction.display.plotting import PlotterEngineEnum +from easydiffraction.display.plotting import PlotterFactory +from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.io.cif.parse import read_cif_str +from easydiffraction.project.categories.chart.factory import ChartFactory +from easydiffraction.utils.logging import log + +AUTO_ENGINE = 'auto' +AUTO_DESCRIPTION = 'Environment default chart engine' +CHART_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in PlotterEngineEnum]] + + +@ChartFactory.register +class Chart(CategoryItem, SwitchableCategoryBase): + """Chart engine selection for a project.""" + + _category_code = 'chart' + _owner_attr_name = 'chart' + _swap_method_name = '_swap_chart' + + type_info = TypeInfo( + tag='default', + description='Project chart category', + ) + + def __init__(self) -> None: + super().__init__() + + self._plotter = Plotter() + self._type = StringDescriptor( + name='type', + description='Chart renderer backend type', + value_spec=AttributeSpec( + default=AUTO_ENGINE, + validator=MembershipValidator( + allowed=CHART_ENGINE_OPTIONS, + ), + ), + cif_handler=CifHandler(names=['_chart.type']), + ) + + @staticmethod + def _resolved_engine(value: str) -> str: + if value == AUTO_ENGINE: + return PlotterEngineEnum.default().value + return value + + def _set_type(self, value: str, *, strict: bool = True) -> None: + if value not in CHART_ENGINE_OPTIONS: + msg = ( + f"Unsupported chart type '{value}'. " + f'Supported: {CHART_ENGINE_OPTIONS}. ' + f"For more information, use 'chart.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + + resolved_engine = self._resolved_engine(value) + if self._plotter.engine != resolved_engine: + self._plotter.engine = resolved_engine + self._type.value = value + + @property + def plotter(self) -> Plotter: + """Live plotting facade bound to the owning project.""" + owner = getattr(self, '_parent', None) + if owner is not None: + self._plotter._set_project(owner) + return self._plotter + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return supported chart renderer backends.""" + del filters + return [(AUTO_ENGINE, AUTO_DESCRIPTION), *PlotterFactory.descriptions()] + + def from_cif(self, block: object, idx: int = 0) -> None: + """Populate this chart category from a CIF block.""" + del idx + chart_type = read_cif_str(block, '_chart.type') + if chart_type is None: + return + self._parent._swap_chart(chart_type, strict=False) diff --git a/src/easydiffraction/analysis/categories/bayesian_result/factory.py b/src/easydiffraction/project/categories/chart/factory.py similarity index 71% rename from src/easydiffraction/analysis/categories/bayesian_result/factory.py rename to src/easydiffraction/project/categories/chart/factory.py index 3d437a0df..4c3237bb4 100644 --- a/src/easydiffraction/analysis/categories/bayesian_result/factory.py +++ b/src/easydiffraction/project/categories/chart/factory.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-result factory.""" +"""Factory for project chart categories.""" from __future__ import annotations @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class BayesianResultFactory(FactoryBase): - """Create Bayesian-result categories by tag.""" +class ChartFactory(FactoryBase): + """Create project chart category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/project/categories/rendering/__init__.py b/src/easydiffraction/project/categories/rendering/__init__.py deleted file mode 100644 index 3e9889159..000000000 --- a/src/easydiffraction/project/categories/rendering/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project rendering category exports.""" - -from __future__ import annotations - -from easydiffraction.project.categories.rendering.default import Rendering -from easydiffraction.project.categories.rendering.factory import RenderingFactory diff --git a/src/easydiffraction/project/categories/rendering/default.py b/src/easydiffraction/project/categories/rendering/default.py deleted file mode 100644 index 179162a08..000000000 --- a/src/easydiffraction/project/categories/rendering/default.py +++ /dev/null @@ -1,165 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project rendering category.""" - -from __future__ import annotations - -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import MembershipValidator -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.display.plotting import Plotter -from easydiffraction.display.plotting import PlotterEngineEnum -from easydiffraction.display.tables import TableEngineEnum -from easydiffraction.display.tables import TableRenderer -from easydiffraction.io.cif.handler import CifHandler -from easydiffraction.io.cif.parse import read_cif_str -from easydiffraction.project.categories.rendering.factory import RenderingFactory -from easydiffraction.utils.logging import console -from easydiffraction.utils.utils import render_table - -AUTO_ENGINE = 'auto' -CHART_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in PlotterEngineEnum]] -TABLE_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in TableEngineEnum]] - - -@RenderingFactory.register -class Rendering(CategoryItem): - """Chart and table engine selection for a project.""" - - _category_code = 'rendering' - - type_info = TypeInfo( - tag='default', - description='Project rendering category', - ) - - def __init__(self) -> None: - super().__init__() - - self._plotter = Plotter() - self._tabler = TableRenderer.get() - - # Persist symbolic "auto" so project.cif stays portable. - self._chart_engine = StringDescriptor( - name='chart_engine', - description='Chart renderer backend type', - value_spec=AttributeSpec( - default=AUTO_ENGINE, - validator=MembershipValidator( - allowed=CHART_ENGINE_OPTIONS, - ), - ), - cif_handler=CifHandler(names=['_rendering.chart_engine']), - ) - self._table_engine = StringDescriptor( - name='table_engine', - description='Table renderer backend type', - value_spec=AttributeSpec( - default=AUTO_ENGINE, - validator=MembershipValidator( - allowed=TABLE_ENGINE_OPTIONS, - ), - ), - cif_handler=CifHandler(names=['_rendering.table_engine']), - ) - - @staticmethod - def _resolved_chart_engine(value: str) -> str: - if value == AUTO_ENGINE: - return PlotterEngineEnum.default().value - return value - - @staticmethod - def _resolved_table_engine(value: str) -> str: - if value == AUTO_ENGINE: - return TableEngineEnum.default().value - return value - - def _set_chart_engine(self, value: str) -> None: - if value not in CHART_ENGINE_OPTIONS: - self._plotter.engine = value - return - - resolved_engine = self._resolved_chart_engine(value) - if self._plotter.engine != resolved_engine: - self._plotter.engine = resolved_engine - self._chart_engine.value = value - - def _set_table_engine(self, value: str) -> None: - if value not in TABLE_ENGINE_OPTIONS: - self._tabler.engine = value - return - - resolved_engine = self._resolved_table_engine(value) - if self._tabler.engine != resolved_engine: - self._tabler.engine = resolved_engine - self._table_engine.value = value - - @property - def chart_engine(self) -> StringDescriptor: - """Chart renderer backend type.""" - return self._chart_engine - - @chart_engine.setter - def chart_engine(self, value: str) -> None: - self._set_chart_engine(value) - - @property - def table_engine(self) -> StringDescriptor: - """Table renderer backend type.""" - return self._table_engine - - @table_engine.setter - def table_engine(self, value: str) -> None: - self._set_table_engine(value) - - @property - def plotter(self) -> Plotter: - """Live plotting facade bound to the owning project.""" - direct_parent = getattr(self, '_parent', None) - owner = direct_parent - while owner is not None and not hasattr(owner, 'structures'): - owner = getattr(owner, '_parent', None) - if owner is None: - owner = direct_parent - if owner is not None: - self._plotter._set_project(owner) - return self._plotter - - @property - def tabler(self) -> TableRenderer: - """Live table-rendering facade.""" - return self._tabler - - def show_chart_engines(self) -> None: - """Print supported chart renderer backends.""" - self.plotter.show_supported_engines() - - def show_table_engines(self) -> None: - """Print supported table renderer backends.""" - self.tabler.show_supported_engines() - - def show_config(self) -> None: - """Print the current rendering configuration.""" - console.paragraph('Current rendering configuration') - render_table( - columns_headers=['Setting', 'Value'], - columns_alignment=['left', 'left'], - columns_data=[ - ['Chart engine', self.chart_engine.value], - ['Table engine', self.table_engine.value], - ], - ) - - def from_cif(self, block: object, idx: int = 0) -> None: - """Populate this rendering category from a CIF block.""" - del idx - chart_engine = read_cif_str(block, '_rendering.chart_engine') - if chart_engine is not None: - self._set_chart_engine(chart_engine) - - table_engine = read_cif_str(block, '_rendering.table_engine') - if table_engine is not None: - self._set_table_engine(table_engine) diff --git a/src/easydiffraction/project/categories/rendering/factory.py b/src/easydiffraction/project/categories/rendering/factory.py deleted file mode 100644 index c2bdf7c5f..000000000 --- a/src/easydiffraction/project/categories/rendering/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Factory for project rendering categories.""" - -from __future__ import annotations - -from typing import ClassVar - -from easydiffraction.core.factory import FactoryBase - - -class RenderingFactory(FactoryBase): - """Create project rendering category instances.""" - - _default_rules: ClassVar[dict] = { - frozenset(): 'default', - } diff --git a/src/easydiffraction/project/categories/table/__init__.py b/src/easydiffraction/project/categories/table/__init__.py new file mode 100644 index 000000000..810cb5c97 --- /dev/null +++ b/src/easydiffraction/project/categories/table/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project table category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.table.default import Table +from easydiffraction.project.categories.table.factory import TableFactory diff --git a/src/easydiffraction/project/categories/table/default.py b/src/easydiffraction/project/categories/table/default.py new file mode 100644 index 000000000..00aa5f47a --- /dev/null +++ b/src/easydiffraction/project/categories/table/default.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project table category.""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.display.tables import TableEngineEnum +from easydiffraction.display.tables import TableRenderer +from easydiffraction.display.tables import TableRendererFactory +from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.io.cif.parse import read_cif_str +from easydiffraction.project.categories.table.factory import TableFactory +from easydiffraction.utils.logging import log + +AUTO_ENGINE = 'auto' +AUTO_DESCRIPTION = 'Environment default table engine' +TABLE_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in TableEngineEnum]] + + +@TableFactory.register +class Table(CategoryItem, SwitchableCategoryBase): + """Table engine selection for a project.""" + + _category_code = 'table' + _owner_attr_name = 'table' + _swap_method_name = '_swap_table' + + type_info = TypeInfo( + tag='default', + description='Project table category', + ) + + def __init__(self) -> None: + super().__init__() + + self._tabler = TableRenderer.get() + self._type = StringDescriptor( + name='type', + description='Table renderer backend type', + value_spec=AttributeSpec( + default=AUTO_ENGINE, + validator=MembershipValidator( + allowed=TABLE_ENGINE_OPTIONS, + ), + ), + cif_handler=CifHandler(names=['_table.type']), + ) + + @staticmethod + def _resolved_engine(value: str) -> str: + if value == AUTO_ENGINE: + return TableEngineEnum.default().value + return value + + def _set_type(self, value: str, *, strict: bool = True) -> None: + if value not in TABLE_ENGINE_OPTIONS: + msg = ( + f"Unsupported table type '{value}'. " + f'Supported: {TABLE_ENGINE_OPTIONS}. ' + f"For more information, use 'table.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + + resolved_engine = self._resolved_engine(value) + if self._tabler.engine != resolved_engine: + self._tabler.engine = resolved_engine + self._type.value = value + + @property + def tabler(self) -> TableRenderer: + """Live table-rendering facade.""" + return self._tabler + + @staticmethod + def _supported_types( + filters: dict[str, object], + ) -> list[tuple[str, str]]: + """Return supported table renderer backends.""" + del filters + return [(AUTO_ENGINE, AUTO_DESCRIPTION), *TableRendererFactory.descriptions()] + + def from_cif(self, block: object, idx: int = 0) -> None: + """Populate this table category from a CIF block.""" + del idx + table_type = read_cif_str(block, '_table.type') + if table_type is None: + return + self._parent._swap_table(table_type, strict=False) diff --git a/src/easydiffraction/analysis/categories/bayesian_sampler/factory.py b/src/easydiffraction/project/categories/table/factory.py similarity index 71% rename from src/easydiffraction/analysis/categories/bayesian_sampler/factory.py rename to src/easydiffraction/project/categories/table/factory.py index 6f5d1033c..eac859310 100644 --- a/src/easydiffraction/analysis/categories/bayesian_sampler/factory.py +++ b/src/easydiffraction/project/categories/table/factory.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian-sampler factory.""" +"""Factory for project table categories.""" from __future__ import annotations @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class BayesianSamplerFactory(FactoryBase): - """Create Bayesian-sampler categories by tag.""" +class TableFactory(FactoryBase): + """Create project table category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index 5b760d997..168d16773 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -98,7 +98,7 @@ def correlations( show_diagonal: bool = True, ) -> None: """Show parameter correlations from the latest fit.""" - self._project.rendering.plotter.plot_param_correlations( + self._project.chart.plotter.plot_param_correlations( threshold=threshold, precision=precision, max_parameters=max_parameters, @@ -120,9 +120,9 @@ def series( another. """ if param is None: - self._project.rendering.plotter.plot_all_param_series(versus=versus) + self._project.chart.plotter.plot_all_param_series(versus=versus) else: - self._project.rendering.plotter.plot_param_series(param=param, versus=versus) + self._project.chart.plotter.plot_param_series(param=param, versus=versus) def help(self) -> None: """Print available fit-display methods.""" @@ -147,13 +147,13 @@ def _pairs_need_processing_indicator( return True analysis = self._project.analysis + fit_results = getattr(analysis, 'fit_results', None) + runtime_pair_caches = getattr(fit_results, 'posterior_pair_caches', None) + if runtime_pair_caches: + return False + sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) - pair_caches = sidecar_data.get('pair_caches', {}) - return not ( - analysis.bayesian_result.has_pair_cache.value - and len(analysis.bayesian_pair_caches) > 0 - and bool(pair_caches) - ) + return not bool(sidecar_data.get('pair_caches', {})) def _predictive_needs_processing_indicator( self, @@ -164,38 +164,39 @@ def _predictive_needs_processing_indicator( ) -> bool: """Return whether predictive plotting still needs processing.""" analysis = self._project.analysis - sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) - predictive_datasets = sidecar_data.get('predictive_datasets', {}) - if not ( - analysis.bayesian_result.has_posterior_predictive.value - and bool(predictive_datasets) - and expt_name in predictive_datasets - ): - return True - experiment = self._project.experiments[expt_name] - plotter = self._project.rendering.plotter + plotter = self._project.chart.plotter _, x_axis_name, _, _, _ = plotter._resolve_x_axis(experiment.type, x) + x_axis_name = str(x_axis_name) require_draws = plotter.engine == PlotterEngineEnum.PLOTLY.value and style in { 'draws', 'band+draws', } - matching_rows = [ - row - for row in analysis.bayesian_predictive_datasets - if row.experiment_name.value == expt_name - and str(row.x_axis_name.value) == str(x_axis_name) - ] - if not matching_rows: + sidecar_data = getattr(analysis, '_persisted_fit_state_sidecar', {}) + predictive_dataset = sidecar_data.get('predictive_datasets', {}).get(expt_name) + if predictive_dataset is not None: + dataset_axis_name = str(predictive_dataset.get('x_axis_name', '')) + if dataset_axis_name in {'', x_axis_name}: + return require_draws and predictive_dataset.get('draws') is None + + fit_results = getattr(analysis, 'fit_results', None) + posterior_predictive = getattr(fit_results, 'posterior_predictive', None) + if not posterior_predictive: return True - if not require_draws: - return False - return not any( - row.draws_path.value is not None - and predictive_datasets[expt_name].get('draws') is not None - for row in matching_rows - ) + + cache_keys = [ + plotter._posterior_predictive_key(expt_name, x_axis_name, include_draws=True), + plotter._posterior_predictive_key(expt_name, x_axis_name, include_draws=False), + expt_name, + ] + for cache_key in cache_keys: + summary = posterior_predictive.get(cache_key) + if summary is None or str(getattr(summary, 'x_axis_name', '')) != x_axis_name: + continue + return require_draws and getattr(summary, 'draws', None) is None + + return True def pairs( self, @@ -215,7 +216,7 @@ def pairs( else nullcontext() ) with indicator_context: - self._project.rendering.plotter.plot_posterior_pairs( + self._project.chart.plotter.plot_posterior_pairs( parameters=parameters, style=style, threshold=threshold, @@ -226,7 +227,7 @@ def distribution(self, param: object | None = None) -> None: """ Plot posterior distributions for one or all free parameters. """ - plotter = self._project.rendering.plotter + plotter = self._project.chart.plotter if param is not None: plotter.plot_param_distribution(param) return @@ -263,7 +264,7 @@ def predictive( else nullcontext() ) with indicator_context: - self._project.rendering.plotter.plot_posterior_predictive( + self._project.chart.plotter.plot_posterior_predictive( expt_name=expt_name, style=style, x_min=x_min, @@ -339,7 +340,7 @@ def pattern( else nullcontext() ) with indicator_context: - self._project.rendering.plotter._plot_posterior_predictive_request( + self._project.chart.plotter._plot_posterior_predictive_request( expt_name=expt_name, style='band', plot_options=_MeasVsCalcPlotOptions( @@ -382,7 +383,7 @@ def pattern( else nullcontext() ) with indicator_context: - self._project.rendering.plotter._plot_posterior_predictive_request( + self._project.chart.plotter._plot_posterior_predictive_request( expt_name=expt_name, style='band', plot_options=_MeasVsCalcPlotOptions( @@ -556,7 +557,7 @@ def _show_point_estimate_pattern( self._validate_requested_include(statuses, include) include_set = set(include) if include_set == {'measured'}: - self._project.rendering.plotter.plot_meas( + self._project.chart.plotter.plot_meas( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -565,7 +566,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'measured', 'excluded'}: - self._project.rendering.plotter.plot_meas( + self._project.chart.plotter.plot_meas( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -574,7 +575,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'calculated'}: - self._project.rendering.plotter.plot_calc( + self._project.chart.plotter.plot_calc( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -583,7 +584,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'calculated', 'excluded'}: - self._project.rendering.plotter.plot_calc( + self._project.chart.plotter.plot_calc( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -592,7 +593,7 @@ def _show_point_estimate_pattern( ) return if {'measured', 'calculated'}.issubset(include_set): - self._project.rendering.plotter._plot_meas_vs_calc_request( + self._project.chart.plotter._plot_meas_vs_calc_request( expt_name=expt_name, plot_options=_MeasVsCalcPlotOptions( x_min=x_min, @@ -614,7 +615,7 @@ def _show_point_estimate_pattern( def _pattern_option_statuses(self, expt_name: str) -> list[PatternOptionStatus]: """Return availability details for the requested experiment.""" - self._project.rendering.plotter._update_project_categories(expt_name) + self._project.chart.plotter._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] pattern = intensity_category_for(experiment) sample_form = experiment.type.sample_form.value @@ -838,10 +839,9 @@ def _uncertainty_status( if not posterior_predictive: return False, 'Posterior predictive data is unavailable.' - active_chart_engine = getattr(self._project.rendering.plotter, 'engine', None) + active_chart_engine = getattr(self._project.chart.plotter, 'engine', None) if active_chart_engine is None: - chart_engine = getattr(self._project.rendering, 'chart_engine', None) - active_chart_engine = getattr(chart_engine, 'value', None) + active_chart_engine = self._project.chart.type if active_chart_engine != PlotterEngineEnum.PLOTLY.value: return False, 'Uncertainty bands currently require the Plotly chart engine.' diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index 9b89eb500..a9172be35 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -34,7 +34,8 @@ if TYPE_CHECKING: from collections.abc import Callable - from easydiffraction.project.categories.rendering import Rendering + from easydiffraction.project.categories.chart import Chart + from easydiffraction.project.categories.table import Table from easydiffraction.project.categories.verbosity import Verbosity from easydiffraction.project.project_info import ProjectInfo @@ -174,7 +175,7 @@ def _load_project_analysis(project: Project, project_path: pathlib.Path) -> None project._analysis._restore_live_parameter_state(project._build_parameter_map()) -class Project(GuardedBase): +class Project(GuardedBase): # noqa: PLR0904 """ Central API for managing a diffraction data analysis project. @@ -200,7 +201,8 @@ def __init__( object.__setattr__(self, '_info', self._config.info) self._structures = Structures() self._experiments = Experiments() - object.__setattr__(self, '_rendering', self._config.rendering) + object.__setattr__(self, '_chart', self._config.chart) + object.__setattr__(self, '_table', self._config.table) object.__setattr__(self, '_verbosity', self._config.verbosity) self._display = ProjectDisplay(self) self._analysis = Analysis(self) @@ -208,6 +210,29 @@ def __init__( self._saved = False self._varname = 'project' if type(self)._loading else varname() type(self)._current_project = self + self._attach_category_parents() + + def _attach_category_parents(self) -> None: + """Link directly owned project sections back to this project.""" + self._structures._parent = self + self._experiments._parent = self + self._analysis._parent = self + self._chart._parent = self + self._table._parent = self + + @staticmethod + def _supported_filters_for(category: object) -> dict[str, object]: + """Return owner context filters for a switchable category.""" + del category + return {} + + def _swap_chart(self, new_type: str, *, strict: bool = True) -> None: + """Switch the active chart renderer.""" + self._chart._set_type(new_type, strict=strict) + + def _swap_table(self, new_type: str, *, strict: bool = True) -> None: + """Switch the active table renderer.""" + self._table._set_type(new_type, strict=strict) @classmethod def current_project_path(cls) -> pathlib.Path | None: @@ -279,9 +304,14 @@ def experiments(self, experiments: Experiments) -> None: self._experiments = experiments @property - def rendering(self) -> Rendering: - """Rendering configuration bound to the project.""" - return self._rendering + def chart(self) -> Chart: + """Chart configuration bound to the project.""" + return self._chart + + @property + def table(self) -> Table: + """Table configuration bound to the project.""" + return self._table @property def display(self) -> ProjectDisplay: diff --git a/src/easydiffraction/project/project_config.py b/src/easydiffraction/project/project_config.py index dd92137ab..725c07090 100644 --- a/src/easydiffraction/project/project_config.py +++ b/src/easydiffraction/project/project_config.py @@ -5,10 +5,12 @@ from __future__ import annotations from easydiffraction.core.category_owner import CategoryOwner +from easydiffraction.project.categories.chart import Chart +from easydiffraction.project.categories.chart import ChartFactory from easydiffraction.project.categories.info import ProjectInfo from easydiffraction.project.categories.info import ProjectInfoFactory -from easydiffraction.project.categories.rendering import Rendering -from easydiffraction.project.categories.rendering import RenderingFactory +from easydiffraction.project.categories.table import Table +from easydiffraction.project.categories.table import TableFactory from easydiffraction.project.categories.verbosity import Verbosity from easydiffraction.project.categories.verbosity import VerbosityFactory @@ -29,7 +31,8 @@ def __init__( title=title, description=description, ) - self._rendering = RenderingFactory.create(RenderingFactory.default_tag()) + self._chart = ChartFactory.create(ChartFactory.default_tag()) + self._table = TableFactory.create(TableFactory.default_tag()) self._verbosity = VerbosityFactory.create(VerbosityFactory.default_tag()) @property @@ -38,9 +41,14 @@ def info(self) -> ProjectInfo: return self._info @property - def rendering(self) -> Rendering: - """Rendering configuration category.""" - return self._rendering + def chart(self) -> Chart: + """Chart configuration category.""" + return self._chart + + @property + def table(self) -> Table: + """Table configuration category.""" + return self._table @property def verbosity(self) -> Verbosity: diff --git a/src/easydiffraction/summary/summary.py b/src/easydiffraction/summary/summary.py index 25977bfa9..66dec0b8e 100644 --- a/src/easydiffraction/summary/summary.py +++ b/src/easydiffraction/summary/summary.py @@ -154,7 +154,7 @@ def show_experimental_data(self) -> None: ) console.paragraph('Calculation engine') - console.print(f'{expt.calculation.calculator_type.value}') + console.print(f'{expt.calculator.type}') if 'instrument' in expt._public_attrs(): if 'setup_wavelength' in expt.instrument._public_attrs(): @@ -164,9 +164,9 @@ def show_experimental_data(self) -> None: console.paragraph('2θ offset') console.print(f'{expt.instrument.calib_twotheta_offset.value:.5f}') - if 'peak_profile_type' in expt._public_attrs(): + if 'peak' in expt._public_attrs(): console.paragraph('Profile type') - console.print(expt.peak_profile_type) + console.print(expt.peak.type) if 'peak' in expt._public_attrs(): if 'broad_gauss_u' in expt.peak._public_attrs(): @@ -219,7 +219,7 @@ def show_fitting_details(self) -> None: console.section('Fitting') console.paragraph('Minimization engine') - console.print(self.project.analysis.fitting.minimizer_type.value) + console.print(self.project.analysis.minimizer.type) console.paragraph('Fit quality') columns_headers = ['metric', 'value'] diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py index 6dba4301d..e07b15755 100644 --- a/src/easydiffraction/utils/utils.py +++ b/src/easydiffraction/utils/utils.py @@ -700,11 +700,11 @@ def _help_property_rows(cls: type) -> list[list[str]]: seen[key] = attr rows = [] - for i, key in enumerate(sorted(seen), 1): + for key in sorted(seen): prop = seen[key] - writable = '✓' if prop.fset else '✗' + writable = '✓' if prop.fset else '' doc = _help_first_sentence(prop.fget.__doc__ if prop.fget else None) - rows.append([str(i), key, writable, doc]) + rows.append([key, writable, doc]) return rows @@ -726,13 +726,13 @@ def _help_method_rows(cls: type) -> list[list[str]]: methods.append((key, raw)) rows = [] - for i, (key, method) in enumerate(sorted(methods), 1): + for key, method in sorted(methods): doc = _help_first_sentence(getattr(method, '__doc__', None)) - rows.append([str(i), f'{key}()', doc]) + rows.append([f'{key}()', doc]) return rows -def render_object_help(obj: object, title: str | None = None) -> None: +def render_object_help(obj: object) -> None: """ Print public properties and methods for a plain helper object. @@ -740,19 +740,15 @@ def render_object_help(obj: object, title: str | None = None) -> None: ---------- obj : object Object whose public API should be summarized. - title : str | None, default=None - Optional display name. Uses the class name when omitted. """ cls = type(obj) - display_title = title or cls.__name__ - console.paragraph(f"Help for '{display_title}'") prop_rows = _help_property_rows(cls) if prop_rows: console.paragraph('Properties') render_table( - columns_headers=['#', 'Name', 'Writable', 'Description'], - columns_alignment=['right', 'left', 'center', 'left'], + columns_headers=['Name', 'Writable', 'Description'], + columns_alignment=['left', 'center', 'left'], columns_data=prop_rows, ) @@ -760,8 +756,8 @@ def render_object_help(obj: object, title: str | None = None) -> None: if method_rows: console.paragraph('Methods') render_table( - columns_headers=['#', 'Name', 'Description'], - columns_alignment=['right', 'left', 'left'], + columns_headers=['Name', 'Description'], + columns_alignment=['left', 'left'], columns_data=method_rows, ) diff --git a/tests/functional/test_switchable_categories.py b/tests/functional/test_switchable_categories.py index 52bc991bd..71db77df0 100644 --- a/tests/functional/test_switchable_categories.py +++ b/tests/functional/test_switchable_categories.py @@ -45,7 +45,7 @@ def test_fit_default(self): def test_minimizer_default(self): project = _make_project_with_experiment() - assert project.analysis.fitting.minimizer_type is not None + assert project.analysis.minimizer.type is not None # ------------------------------------------------------------------ @@ -54,12 +54,12 @@ def test_minimizer_default(self): class TestExperimentSwitchableCategories: - def test_background_type_has_getter(self): + def test_background_selector_has_getter(self): project = _make_project_with_experiment() expt = project.experiments['e'] - assert expt.background_type is not None + assert expt.background.type is not None - def test_calculation_has_getter(self): + def test_calculator_has_getter(self): project = _make_project_with_experiment() expt = project.experiments['e'] - assert expt.calculation.calculator_type is not None + assert expt.calculator.type is not None diff --git a/tests/integration/fitting/conftest.py b/tests/integration/fitting/conftest.py index 7c5113274..afc1026a7 100644 --- a/tests/integration/fitting/conftest.py +++ b/tests/integration/fitting/conftest.py @@ -76,7 +76,7 @@ def lbco_fitted_project(): project = Project() project.structures.add(model) project.experiments.add(expt) - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' model.cell.length_a.free = True expt.linked_phases['lbco'].scale.free = True diff --git a/tests/integration/fitting/test_analysis_and_fit_category_support.py b/tests/integration/fitting/test_analysis_and_fit_category_support.py index 89c0fd931..1e4ce55a6 100644 --- a/tests/integration/fitting/test_analysis_and_fit_category_support.py +++ b/tests/integration/fitting/test_analysis_and_fit_category_support.py @@ -5,8 +5,6 @@ import re -import pytest - ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]') @@ -87,94 +85,28 @@ def test_fit_mode_enum_members_default_and_descriptions(): assert all(member.description() for member in FitModeEnum) -def test_fitting_instantiation_defaults_and_helpers(): - from easydiffraction.analysis.categories.fitting.default import Fitting - import easydiffraction.analysis.categories.fitting.default as fitting_mod - - fitting = Fitting() - - assert fitting._identity.category_code == 'fitting' - assert fitting.minimizer_type.value == 'lmfit (leastsq)' - assert fitting.minimizer is None - - class ParentWithMinimizer: - fitter = type('FitterHolder', (), {'minimizer': 'MIN'})() - - fitting._parent = ParentWithMinimizer() - assert fitting.minimizer == 'MIN' - - shown: list[str] = [] - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr( - fitting_mod.MinimizerFactory, - 'show_supported', - lambda: shown.append('shown'), - ) - Fitting.show_available_minimizers() - monkeypatch.undo() - - assert shown == ['shown'] - - -def test_fit_from_cif_warns_on_invalid_minimizer(monkeypatch): - import easydiffraction.analysis.categories.fitting.default as fitting_mod - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - fitting._minimizer_type._value = 'bad-minimizer' - - class Parent: - fitter = None - - warnings: list[str] = [] - fitting._parent = Parent() - monkeypatch.setattr(fitting_mod.CategoryItem, 'from_cif', lambda self, block, idx=0: None) - monkeypatch.setattr( - fitting_mod, - 'Fitter', - lambda value: (_ for _ in ()).throw(ValueError('bad minimizer')), - ) - monkeypatch.setattr(fitting_mod.log, 'warning', lambda message: warnings.append(message)) - - fitting.from_cif(object()) - - assert warnings == ['bad minimizer'] - - -def test_fitting_fallback_paths_without_parent(monkeypatch): - import easydiffraction.analysis.categories.fitting.default as fitting_mod - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - - assert fitting.minimizer is None - - monkeypatch.setattr(fitting_mod.CategoryItem, 'from_cif', lambda self, block, idx=0: None) - fitting.from_cif(object()) - - -def test_show_fitting_mode_types_for_single_and_multiple_experiments(capsys): +def test_fitting_mode_show_supported_for_single_and_multiple_experiments(capsys): from easydiffraction.analysis.analysis import Analysis single = Analysis(project=_make_project_with_names(['e1'])) - single.show_fitting_mode_types() + single.fitting_mode.show_supported() out_single = capsys.readouterr().out - assert 'Fitting mode types' in out_single + assert 'Fitting Mode types' in out_single assert 'single' in out_single assert 'joint' in out_single multi = Analysis(project=_make_project_with_names(['e1', 'e2'])) - multi.show_fitting_mode_types() + multi.fitting_mode.show_supported() out_multi = capsys.readouterr().out assert 'joint' in out_multi assert 'sequential' in out_multi -def test_show_minimizer_types_prints(capsys): +def test_minimizer_show_supported_prints(capsys): from easydiffraction.analysis.analysis import Analysis analysis = Analysis(project=_make_project_with_names([])) - analysis.fitting.show_minimizer_types() + analysis.minimizer.show_supported() out = capsys.readouterr().out assert 'Minimizer types' in out assert 'lmfit (leastsq)' in out @@ -184,14 +116,13 @@ def test_analysis_help_and_mode_switching(capsys): from easydiffraction.analysis.analysis import Analysis analysis = Analysis(project=_make_project_with_names(['e1', 'e2'])) - assert analysis.fitting_mode_type == 'single' - analysis.fitting_mode_type = 'joint' - assert analysis.fitting_mode_type == 'joint' + assert analysis.fitting_mode.type == 'single' + analysis.fitting_mode.type = 'joint' + assert analysis.fitting_mode.type == 'joint' assert len(analysis.joint_fit) == 0 analysis.help() out = _unstyled_output(capsys.readouterr().out) - assert "Help for 'Analysis'" in out assert 'fitting' in out assert 'display' in out assert 'Properties' in out @@ -317,10 +248,10 @@ def _private(self): method_rows = _discover_method_rows(Demo) assert len(property_rows) == 2 - assert 'alpha' in [row[1] for row in property_rows] - assert next(row for row in property_rows if row[1] == 'beta')[2] == '✓' - assert 'do_thing()' in [row[1] for row in method_rows] - assert '_private()' not in [row[1] for row in method_rows] + assert 'alpha' in [row[0] for row in property_rows] + assert next(row for row in property_rows if row[0] == 'beta')[1] == '✓' + assert 'do_thing()' in [row[0] for row in method_rows] + assert '_private()' not in [row[0] for row in method_rows] analysis = Analysis(project=_make_project()) diff --git a/tests/integration/fitting/test_analysis_display.py b/tests/integration/fitting/test_analysis_display.py index 3a139be26..f749bd1a8 100644 --- a/tests/integration/fitting/test_analysis_display.py +++ b/tests/integration/fitting/test_analysis_display.py @@ -57,14 +57,14 @@ def test_analysis_help(lbco_fitted_project): project.analysis.help() -def test_show_minimizer_types_again(lbco_fitted_project): +def test_minimizer_show_supported_again(lbco_fitted_project): project = lbco_fitted_project - project.analysis.fitting.show_minimizer_types() + project.analysis.minimizer.show_supported() -def test_show_minimizer_types(lbco_fitted_project): +def test_minimizer_show_supported(lbco_fitted_project): project = lbco_fitted_project - project.analysis.fitting.show_minimizer_types() + project.analysis.minimizer.show_supported() def test_fit_results_attributes(lbco_fitted_project): diff --git a/tests/integration/fitting/test_bayesian_dream.py b/tests/integration/fitting/test_bayesian_dream.py index ef52c1f1a..b07b59f05 100644 --- a/tests/integration/fitting/test_bayesian_dream.py +++ b/tests/integration/fitting/test_bayesian_dream.py @@ -88,13 +88,14 @@ def _dream_parameters(project: Project) -> tuple[object, object, object]: def _configure_small_dream(project: Project) -> None: - project.analysis.fitting.minimizer_type = 'bumps (dream)' - minimizer = project.analysis.fitting.minimizer - minimizer.steps = 20 - minimizer.burn = 5 - minimizer.thin = 1 - minimizer.pop = 4 - minimizer.init = 'lhs' + project.analysis.minimizer.type = 'bumps (dream)' + minimizer = project.analysis.minimizer + minimizer.sampling_steps = 20 + minimizer.burn_in_steps = 5 + minimizer.thinning_interval = 1 + minimizer.population_size = 4 + minimizer.parallel_workers = 1 + minimizer.initialization_method = 'latin_hypercube' def _run_single_fit(project: Project, *, random_seed: int | None = None) -> None: @@ -144,7 +145,7 @@ def test_lm_prefit_followed_by_dream_uses_uncertainty_based_bounds(): for parameter in (length_a, scale, offset): parameter.free = True - project.analysis.fitting.minimizer_type = 'bumps (lm)' + project.analysis.minimizer.type = 'bumps (lm)' _run_single_fit(project) for parameter in (length_a, scale, offset): diff --git a/tests/integration/fitting/test_exploration_help.py b/tests/integration/fitting/test_exploration_help.py index 08e2bcf54..0063893a1 100644 --- a/tests/integration/fitting/test_exploration_help.py +++ b/tests/integration/fitting/test_exploration_help.py @@ -82,7 +82,8 @@ def fake_render_cif(cif_text): expt.show_as_cif() cif_text = captured['cif_text'] - assert re.search(r'_pd_phase_block\.scale\n[^\n]+\n\nloop_', cif_text) is not None + assert re.search(r'_pd_phase_block\.scale\n[^\n]+\n\n_background\.type', cif_text) is not None + assert re.search(r'_background\.type [^\n]+\nloop_', cif_text) is not None assert '\n\n\n' not in cif_text @@ -100,16 +101,16 @@ def test_experiment_switchable_category_types(lbco_fitted_project): # Instrument assert expt.instrument is not None # Background - expt.show_background_types() - assert isinstance(expt.background_type, str) + expt.background.show_supported() + assert isinstance(expt.background.type, str) # Peak profile - expt.show_peak_profile_types() - assert isinstance(expt.peak_profile_type, str) + expt.peak.show_supported() + assert isinstance(expt.peak.type, str) # Linked phases assert expt.linked_phases is not None # Calculator - expt.calculation.show_calculator_types() - assert isinstance(expt.calculation.calculator_type.value, str) + expt.calculator.show_supported() + assert isinstance(expt.calculator.type, str) # Diffrn assert expt.diffrn is not None diff --git a/tests/integration/fitting/test_multi.py b/tests/integration/fitting/test_multi.py index d75acf6c8..af6e56aa0 100644 --- a/tests/integration/fitting/test_multi.py +++ b/tests/integration/fitting/test_multi.py @@ -84,7 +84,7 @@ def test_single_fit_neutron_pd_tof_mcstas_lbco_si() -> None: expt.instrument.calib_d_to_tof_offset = 0.0 expt.instrument.calib_d_to_tof_linear = 58724.76869981215 expt.instrument.calib_d_to_tof_quad = -0.00001 - expt.peak_profile_type = 'jorgensen' + expt.peak.type = 'jorgensen' expt.peak.broad_gauss_sigma_0 = 45137 expt.peak.broad_gauss_sigma_1 = -52394 expt.peak.broad_gauss_sigma_2 = 22998 @@ -107,7 +107,7 @@ def test_single_fit_neutron_pd_tof_mcstas_lbco_si() -> None: project.experiments['mcstas'].excluded_regions.create(start=108000, end=200000) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # Select fitting parameters model_1.cell.length_a.free = True @@ -165,7 +165,7 @@ def _test_joint_fit_bragg_pdf_neutron_pd_tof_si() -> None: bragg_expt.instrument.calib_d_to_tof_offset = 0.0 bragg_expt.instrument.calib_d_to_tof_linear = 7476.91 bragg_expt.instrument.calib_d_to_tof_quad = -1.54 - bragg_expt.peak_profile_type = 'jorgensen' + bragg_expt.peak.type = 'jorgensen' bragg_expt.peak.broad_gauss_sigma_0 = 3.0 bragg_expt.peak.broad_gauss_sigma_1 = 40.0 bragg_expt.peak.broad_gauss_sigma_2 = 2.0 @@ -200,8 +200,8 @@ def _test_joint_fit_bragg_pdf_neutron_pd_tof_si() -> None: project.experiments.add(pdf_expt) # Prepare for fitting - project.analysis.fitting_mode_type = 'joint' - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.fitting_mode.type = 'joint' + project.analysis.minimizer.type = 'lmfit' # Select fitting parameters — shared structure model.cell.length_a.free = True diff --git a/tests/integration/fitting/test_pair-distribution-function.py b/tests/integration/fitting/test_pair-distribution-function.py index 3d3c4b97e..58910532c 100644 --- a/tests/integration/fitting/test_pair-distribution-function.py +++ b/tests/integration/fitting/test_pair-distribution-function.py @@ -50,7 +50,7 @@ def test_single_fit_pdf_xray_pd_cw_nacl() -> None: scattering_type='total', ) experiment = project.experiments['xray_pdf'] - experiment.peak_profile_type = 'gaussian-damped-sinc' + experiment.peak.type = 'gaussian-damped-sinc' experiment.peak.damp_q = 0.0606 experiment.peak.broad_q = 0 experiment.peak.cutoff_q = 21 diff --git a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py index 0ffa95f14..d4c22b857 100644 --- a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py +++ b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py @@ -86,7 +86,7 @@ def test_single_fit_neutron_pd_cwl_lbco() -> None: project.experiments.add(expt) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # ------------ 1st fitting ------------ @@ -234,7 +234,7 @@ def test_single_fit_neutron_pd_cwl_lbco_with_constraints() -> None: project.experiments.add(expt) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # ------------ 1st fitting ------------ @@ -400,7 +400,7 @@ def test_fit_neutron_pd_cwl_hs() -> None: project.experiments.add(expt) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # ------------ 1st fitting ------------ diff --git a/tests/integration/fitting/test_powder-diffraction_joint-fit.py b/tests/integration/fitting/test_powder-diffraction_joint-fit.py index 0e7e11898..b3b224b6f 100644 --- a/tests/integration/fitting/test_powder-diffraction_joint-fit.py +++ b/tests/integration/fitting/test_powder-diffraction_joint-fit.py @@ -79,7 +79,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None: expt1.peak.broad_lorentz_x = 0 expt1.peak.broad_lorentz_y = 0.0878 expt1.linked_phases.create(id='pbso4', scale=1.46) - expt1.background_type = 'line-segment' + expt1.background.type = 'line-segment' for id, x, y in [ ('1', 11.0, 206.1624), ('2', 15.0, 194.75), @@ -102,7 +102,7 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None: expt2.peak.broad_lorentz_x = 0 expt2.peak.broad_lorentz_y = 0.0878 expt2.linked_phases.create(id='pbso4', scale=1.46) - expt2.background_type = 'line-segment' + expt2.background.type = 'line-segment' for id, x, y in [ ('1', 11.0, 206.1624), ('2', 15.0, 194.75), @@ -122,8 +122,8 @@ def test_joint_fit_split_dataset_neutron_pd_cwl_pbso4() -> None: project.experiments.add(expt2) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' - project.analysis.fitting_mode_type = 'joint' + project.analysis.minimizer.type = 'lmfit' + project.analysis.fitting_mode.type = 'joint' # Select fitting parameters model.cell.length_a.free = True @@ -255,7 +255,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None: project.experiments.add(expt2) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # Select fitting parameters model.cell.length_a.free = True @@ -279,7 +279,7 @@ def test_joint_fit_neutron_xray_pd_cwl_pbso4() -> None: # ------------ 2nd fitting ------------ # Perform fit - project.analysis.fitting_mode_type = 'joint' + project.analysis.fitting_mode.type = 'joint' project.analysis.fit() # Compare fit quality diff --git a/tests/integration/fitting/test_powder-diffraction_time-of-flight.py b/tests/integration/fitting/test_powder-diffraction_time-of-flight.py index 496852e1d..fffc54400 100644 --- a/tests/integration/fitting/test_powder-diffraction_time-of-flight.py +++ b/tests/integration/fitting/test_powder-diffraction_time-of-flight.py @@ -40,7 +40,7 @@ def test_single_fit_neutron_pd_tof_si() -> None: expt.instrument.calib_d_to_tof_offset = -9.29 expt.instrument.calib_d_to_tof_linear = 7476.91 expt.instrument.calib_d_to_tof_quad = -1.54 - expt.peak_profile_type = 'jorgensen' + expt.peak.type = 'jorgensen' expt.peak.broad_gauss_sigma_0 = 4.2 expt.peak.broad_gauss_sigma_1 = 45.8 expt.peak.broad_gauss_sigma_2 = 1.1 @@ -58,7 +58,7 @@ def test_single_fit_neutron_pd_tof_si() -> None: project.experiments.add(expt) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # Select fitting parameters model.cell.length_a.free = True @@ -153,7 +153,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None: expt.instrument.calib_d_to_tof_offset = -13.7123 expt.instrument.calib_d_to_tof_linear = 20773.1 expt.instrument.calib_d_to_tof_quad = -1.08308 - expt.peak_profile_type = 'jorgensen' + expt.peak.type = 'jorgensen' expt.peak.broad_gauss_sigma_0 = 0.0 expt.peak.broad_gauss_sigma_1 = 0.0 expt.peak.broad_gauss_sigma_2 = 15.7 @@ -200,7 +200,7 @@ def test_single_fit_neutron_pd_tof_ncaf() -> None: project.experiments.add(expt) # Prepare for fitting - project.analysis.fitting.minimizer_type = 'lmfit' + project.analysis.minimizer.type = 'lmfit' # Select fitting parameters expt.linked_phases['ncaf'].scale.free = True diff --git a/tests/integration/fitting/test_project_load.py b/tests/integration/fitting/test_project_load.py index f908789a6..368a35b2b 100644 --- a/tests/integration/fitting/test_project_load.py +++ b/tests/integration/fitting/test_project_load.py @@ -212,11 +212,8 @@ def test_save_load_round_trip_preserves_parameters(tmp_path) -> None: assert loaded.analysis.constraints.enabled is True # Compare analysis settings - assert ( - loaded.analysis.fitting.minimizer_type.value - == original.analysis.fitting.minimizer_type.value - ) - assert loaded.analysis.fitting_mode_type == original.analysis.fitting_mode_type + assert loaded.analysis.minimizer.type == original.analysis.minimizer.type + assert loaded.analysis.fitting_mode.type == original.analysis.fitting_mode.type # ------------------------------------------------------------------ diff --git a/tests/integration/fitting/test_sequential.py b/tests/integration/fitting/test_sequential.py index 73c068b3b..37d5bd26a 100644 --- a/tests/integration/fitting/test_sequential.py +++ b/tests/integration/fitting/test_sequential.py @@ -134,7 +134,7 @@ def _run_sequential_fit( file_pattern: str = '*', reverse: bool = False, ) -> None: - project.analysis.fitting_mode_type = 'sequential' + project.analysis.fitting_mode.type = 'sequential' project.analysis.sequential_fit.data_dir = data_dir project.analysis.sequential_fit.max_workers = ( 'auto' if max_workers == 'auto' else str(max_workers) diff --git a/tests/integration/fitting/test_switch-calculator.py b/tests/integration/fitting/test_switch-calculator.py index 15b7bd7a5..e633b5c74 100644 --- a/tests/integration/fitting/test_switch-calculator.py +++ b/tests/integration/fitting/test_switch-calculator.py @@ -51,10 +51,10 @@ def test_neutron_pd_cwl_lbco_crysfml(tmp_path) -> None: project = ed.Project.load(proj_dir) # Change calculator - project.experiments['hrpt'].calculation.calculator_type = 'crysfml' + project.experiments['hrpt'].calculator.type = 'crysfml' # Compare calculator - assert project.experiments['hrpt'].calculation.calculator_type.value == 'crysfml' + assert project.experiments['hrpt'].calculator.type == 'crysfml' # Perform Analysis 1 project.analysis.fit() diff --git a/tests/unit/easydiffraction/analysis/categories/fitting/test_default.py b/tests/unit/easydiffraction/analysis/categories/fitting/test_default.py deleted file mode 100644 index 094a033a3..000000000 --- a/tests/unit/easydiffraction/analysis/categories/fitting/test_default.py +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/fitting/default.py.""" - -from types import SimpleNamespace - - -def test_fitting_defaults(): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - - assert fitting.minimizer_type.value == 'lmfit (leastsq)' - assert fitting._identity.category_code == 'fitting' - assert Fitting.type_info.tag == 'default' - - -def test_fitting_setter_updates_parent_fitter(): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - parent = SimpleNamespace(fitter=None) - fitting._parent = parent - - fitting.minimizer_type = 'lmfit' - - assert fitting.minimizer_type.value == 'lmfit' - assert parent.fitter is not None - - -def test_fitting_as_cif_uses_fitting_prefix(): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - fitting.minimizer_type = 'lmfit' - - assert '_fitting.minimizer_type' in fitting.as_cif diff --git a/tests/unit/easydiffraction/analysis/categories/fitting/test_factory.py b/tests/unit/easydiffraction/analysis/categories/fitting/test_factory.py deleted file mode 100644 index 24d248f1e..000000000 --- a/tests/unit/easydiffraction/analysis/categories/fitting/test_factory.py +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/fitting/factory.py.""" - - -def test_fitting_factory_supported_tags(): - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - assert 'default' in FittingFactory.supported_tags() - - -def test_fitting_factory_default_tag(): - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - assert FittingFactory.default_tag() == 'default' - - -def test_fitting_factory_create(): - from easydiffraction.analysis.categories.fitting.default import Fitting - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - fitting = FittingFactory.create('default') - - assert isinstance(fitting, Fitting) diff --git a/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_default.py b/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_default.py new file mode 100644 index 000000000..f6461774b --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_default.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gemmi + + +class _Parent: + def __init__(self, fitting_mode): + self.fitting_mode = fitting_mode + self.swap_calls: list[str] = [] + + def _supported_filters_for(self, category: object) -> dict[str, object]: + assert category is self.fitting_mode + return {} + + def _swap_fitting_mode(self, value: str) -> None: + self.swap_calls.append(value) + self.fitting_mode._type.value = value + + +def test_fitting_mode_defaults(): + from easydiffraction.analysis.categories.fitting_mode.default import FittingMode + + fitting_mode = FittingMode() + + assert fitting_mode.type_info.tag == 'default' + assert fitting_mode._identity.category_code == 'fitting_mode' + assert fitting_mode.type == 'single' + + +def test_fitting_mode_selector_delegates_to_parent(): + from easydiffraction.analysis.categories.fitting_mode.default import FittingMode + + fitting_mode = FittingMode() + parent = _Parent(fitting_mode) + fitting_mode._parent = parent + + fitting_mode.type = 'joint' + + assert parent.swap_calls == ['joint'] + assert fitting_mode.type == 'joint' + + +def test_fitting_mode_supported_types_include_all_modes(): + from easydiffraction.analysis.categories.fitting_mode.default import FittingMode + + supported = FittingMode()._supported_types({}) + tags = [tag for tag, _description in supported] + + assert tags == ['single', 'joint', 'sequential'] + + +def test_fitting_mode_from_cif_restores_value_without_parent(): + from easydiffraction.analysis.categories.fitting_mode.default import FittingMode + + fitting_mode = FittingMode() + block = gemmi.cif.read_string( + 'data_test\n_fitting_mode.type sequential\n', + ).sole_block() + + fitting_mode.from_cif(block) + + assert fitting_mode.type == 'sequential' diff --git a/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_factory.py b/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_factory.py new file mode 100644 index 000000000..2408f1960 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fitting_mode/test_factory.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_fitting_mode_factory_default_and_create(): + from easydiffraction.analysis.categories.fitting_mode.default import FittingMode + from easydiffraction.analysis.categories.fitting_mode.factory import FittingModeFactory + + assert FittingModeFactory.default_tag() == 'default' + assert 'default' in FittingModeFactory.supported_tags() + + fitting_mode = FittingModeFactory.create('default') + + assert isinstance(fitting_mode, FittingMode) + + +def test_fitting_mode_factory_rejects_unknown_tag(): + from easydiffraction.analysis.categories.fitting_mode.factory import FittingModeFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + FittingModeFactory.create('missing') diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_base.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_base.py new file mode 100644 index 000000000..4efff614a --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_base.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for minimizer category base helpers.""" + +from __future__ import annotations + + +def test_descriptor_values_and_native_kwargs_use_descriptor_values(): + from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( + LmfitLeastsqMinimizer, + ) + + minimizer = LmfitLeastsqMinimizer() + minimizer.max_iterations = 25 + + assert minimizer._descriptor_values(('max_iterations',)) == { + 'max_iterations': 25, + } + assert minimizer._native_kwargs() == {'max_iterations': 25} diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py new file mode 100644 index 000000000..576ac976a --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for Bayesian minimizer category behavior.""" + +from __future__ import annotations + +import gemmi +import pytest + + +def test_bayesian_minimizer_defaults_and_native_kwargs(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + + minimizer = BumpsDreamMinimizer() + + assert minimizer.sampling_steps.value == 3000 + assert minimizer.burn_in_steps.value == 600 + assert minimizer.thinning_interval.value == 1 + assert minimizer.population_size.value == 4 + assert minimizer.parallel_workers.value == 0 + assert minimizer.initialization_method.value == 'latin_hypercube' + assert minimizer._native_kwargs() == { + 'steps': 3000, + 'burn': 600, + 'thin': 1, + 'pop': 4, + 'parallel': 0, + 'init': 'lhs', + 'random_seed': None, + } + + +def test_bayesian_minimizer_rejects_unsupported_initialization_method(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + + minimizer = BumpsDreamMinimizer() + + with pytest.raises(ValueError, match='unsupported'): + minimizer.initialization_method = 'ball' + + +def test_bayesian_minimizer_reads_cif_unknown_values_as_defaults(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + + document = gemmi.cif.read_string( + """data_minimizer +_minimizer.sampling_steps 42 +_minimizer.burn_in_steps ? +_minimizer.initialization_method ? +_minimizer.random_seed ? +""" + ) + minimizer = BumpsDreamMinimizer() + minimizer.from_cif(document.sole_block()) + + assert minimizer.sampling_steps.value == 42 + assert minimizer.burn_in_steps.value == 600 + assert minimizer.initialization_method.value == 'latin_hypercube' + assert minimizer.random_seed.value is None diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps.py new file mode 100644 index 000000000..54a08a6b3 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the default BUMPS minimizer category.""" + +from __future__ import annotations + + +def test_bumps_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.bumps import BumpsMinimizer + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(BumpsMinimizer, LeastSquaresMinimizerBase) + assert BumpsMinimizer.type_info.tag == MinimizerTypeEnum.BUMPS diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_amoeba.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_amoeba.py new file mode 100644 index 000000000..50ac280d9 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_amoeba.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the BUMPS amoeba minimizer category.""" + +from __future__ import annotations + + +def test_bumps_amoeba_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.bumps_amoeba import ( + BumpsAmoebaMinimizer, + ) + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(BumpsAmoebaMinimizer, LeastSquaresMinimizerBase) + assert BumpsAmoebaMinimizer.type_info.tag == MinimizerTypeEnum.BUMPS_AMOEBA diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_de.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_de.py new file mode 100644 index 000000000..750505f42 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_de.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the BUMPS de minimizer category.""" + +from __future__ import annotations + + +def test_bumps_de_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.bumps_de import BumpsDeMinimizer + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(BumpsDeMinimizer, LeastSquaresMinimizerBase) + assert BumpsDeMinimizer.type_info.tag == MinimizerTypeEnum.BUMPS_DE diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_dream.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_dream.py new file mode 100644 index 000000000..524fdcfa3 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_dream.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the BUMPS DREAM minimizer category.""" + +from __future__ import annotations + + +def test_bumps_dream_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.bayesian_base import ( + BayesianMinimizerBase, + ) + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(BumpsDreamMinimizer, BayesianMinimizerBase) + assert BumpsDreamMinimizer.type_info.tag == MinimizerTypeEnum.BUMPS_DREAM diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_lm.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_lm.py new file mode 100644 index 000000000..6ac9b727e --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bumps_lm.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the BUMPS lm minimizer category.""" + +from __future__ import annotations + + +def test_bumps_lm_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.bumps_lm import BumpsLmMinimizer + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(BumpsLmMinimizer, LeastSquaresMinimizerBase) + assert BumpsLmMinimizer.type_info.tag == MinimizerTypeEnum.BUMPS_LM diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_dfols.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_dfols.py new file mode 100644 index 000000000..991ad7a0d --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_dfols.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the DFO-LS minimizer category.""" + +from __future__ import annotations + + +def test_dfols_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.dfols import DfolsMinimizer + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(DfolsMinimizer, LeastSquaresMinimizerBase) + assert DfolsMinimizer.type_info.tag == MinimizerTypeEnum.DFOLS diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_factory.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_factory.py new file mode 100644 index 000000000..233efbd17 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_factory.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the minimizer category factory.""" + +from __future__ import annotations + +import pytest + + +def test_factory_default_creates_lmfit_leastsq_minimizer(): + import easydiffraction.analysis.categories.minimizer # noqa: F401 + from easydiffraction.analysis.categories.minimizer.factory import ( + MinimizerCategoryFactory, + ) + from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( + LmfitLeastsqMinimizer, + ) + + minimizer = MinimizerCategoryFactory.create_default_for() + + assert MinimizerCategoryFactory.default_tag() == 'lmfit (leastsq)' + assert isinstance(minimizer, LmfitLeastsqMinimizer) + + +def test_factory_rejects_unsupported_minimizer_type(): + import easydiffraction.analysis.categories.minimizer # noqa: F401 + from easydiffraction.analysis.categories.minimizer.factory import ( + MinimizerCategoryFactory, + ) + + with pytest.raises(ValueError, match='Unsupported type'): + MinimizerCategoryFactory.create('missing') diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit.py new file mode 100644 index 000000000..6002689e2 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the default LMFIT minimizer category.""" + +from __future__ import annotations + + +def test_lmfit_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.lmfit import LmfitMinimizer + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(LmfitMinimizer, LeastSquaresMinimizerBase) + assert LmfitMinimizer.type_info.tag == MinimizerTypeEnum.LMFIT diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_least_squares.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_least_squares.py new file mode 100644 index 000000000..5e6e22af6 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_least_squares.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the LMFIT least_squares minimizer category.""" + +from __future__ import annotations + + +def test_lmfit_least_squares_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.lmfit_least_squares import ( + LmfitLeastSquaresMinimizer, + ) + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(LmfitLeastSquaresMinimizer, LeastSquaresMinimizerBase) + assert LmfitLeastSquaresMinimizer.type_info.tag == (MinimizerTypeEnum.LMFIT_LEAST_SQUARES) diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_leastsq.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_leastsq.py new file mode 100644 index 000000000..caf105a5e --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lmfit_leastsq.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the LMFIT leastsq minimizer category.""" + +from __future__ import annotations + + +def test_lmfit_leastsq_minimizer_registers_expected_tag(): + from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( + LmfitLeastsqMinimizer, + ) + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum + + assert issubclass(LmfitLeastsqMinimizer, LeastSquaresMinimizerBase) + assert LmfitLeastsqMinimizer.type_info.tag == MinimizerTypeEnum.LMFIT_LEASTSQ diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py new file mode 100644 index 000000000..2dcf16563 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for least-squares minimizer category behavior.""" + +from __future__ import annotations + +import gemmi + + +def test_lsq_minimizer_defaults_and_result_reset(): + from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( + LmfitLeastsqMinimizer, + ) + + minimizer = LmfitLeastsqMinimizer() + minimizer._set_objective_name('chi-square') + minimizer._set_objective_value(1.2) + minimizer._set_covariance_available(value=True) + + assert minimizer.max_iterations.value == 1000 + assert minimizer.objective_name.value == 'chi-square' + + minimizer._reset_result_descriptors() + + # LSQ result descriptors default to None so a CIF written before + # any fit emits `?` rather than `''`, `0`, or `False`. See + # minimizer-category-consolidation_review-8 finding F6. + assert minimizer.objective_name.value is None + assert minimizer.objective_value.value is None + assert minimizer.covariance_available.value is None + assert minimizer.n_data_points.value is None + assert minimizer.iterations_performed.value is None + + +def test_lsq_minimizer_reads_cif_unknown_values_as_defaults(): + from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( + LmfitLeastsqMinimizer, + ) + + document = gemmi.cif.read_string( + """data_minimizer +_minimizer.max_iterations 42 +_minimizer.objective_name chi-square +_minimizer.objective_value ? +""" + ) + minimizer = LmfitLeastsqMinimizer() + minimizer.from_cif(document.sole_block()) + + assert minimizer.max_iterations.value == 42 + assert minimizer.objective_name.value == 'chi-square' + assert minimizer.objective_value.value is None diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_convergence.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_convergence.py deleted file mode 100644 index dbcaf7299..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_convergence.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_convergence/.""" - - -def test_bayesian_convergence_factory_create(): - from easydiffraction.analysis.categories.bayesian_convergence.default import ( - BayesianConvergence, - ) - from easydiffraction.analysis.categories.bayesian_convergence.factory import ( - BayesianConvergenceFactory, - ) - - convergence = BayesianConvergenceFactory.create('default') - - assert BayesianConvergenceFactory.default_tag() == 'default' - assert isinstance(convergence, BayesianConvergence) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_distribution_caches.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_distribution_caches.py deleted file mode 100644 index 26e91f732..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_distribution_caches.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_distribution_caches/.""" - - -def test_bayesian_distribution_caches_factory_create(): - from easydiffraction.analysis.categories.bayesian_distribution_caches.default import ( - BayesianDistributionCaches, - ) - from easydiffraction.analysis.categories.bayesian_distribution_caches.factory import ( - BayesianDistributionCachesFactory, - ) - - caches = BayesianDistributionCachesFactory.create('default') - - assert BayesianDistributionCachesFactory.default_tag() == 'default' - assert isinstance(caches, BayesianDistributionCaches) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_pair_caches.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_pair_caches.py deleted file mode 100644 index f4307f2c4..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_pair_caches.py +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_pair_caches/.""" - - -def test_bayesian_pair_caches_factory_create(): - from easydiffraction.analysis.categories.bayesian_pair_caches.default import BayesianPairCaches - from easydiffraction.analysis.categories.bayesian_pair_caches.factory import ( - BayesianPairCachesFactory, - ) - - caches = BayesianPairCachesFactory.create('default') - - assert BayesianPairCachesFactory.default_tag() == 'default' - assert isinstance(caches, BayesianPairCaches) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_parameter_posteriors.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_parameter_posteriors.py deleted file mode 100644 index 18aa5f019..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_parameter_posteriors.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_parameter_posteriors/.""" - - -def test_bayesian_parameter_posteriors_factory_create(): - from easydiffraction.analysis.categories.bayesian_parameter_posteriors.default import ( - BayesianParameterPosteriors, - ) - from easydiffraction.analysis.categories.bayesian_parameter_posteriors.factory import ( - BayesianParameterPosteriorsFactory, - ) - - posteriors = BayesianParameterPosteriorsFactory.create('default') - - assert BayesianParameterPosteriorsFactory.default_tag() == 'default' - assert isinstance(posteriors, BayesianParameterPosteriors) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_predictive_datasets.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_predictive_datasets.py deleted file mode 100644 index 93746fb41..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_predictive_datasets.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_predictive_datasets/.""" - - -def test_bayesian_predictive_datasets_factory_create(): - from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasets, - ) - from easydiffraction.analysis.categories.bayesian_predictive_datasets.factory import ( - BayesianPredictiveDatasetsFactory, - ) - - datasets = BayesianPredictiveDatasetsFactory.create('default') - - assert BayesianPredictiveDatasetsFactory.default_tag() == 'default' - assert isinstance(datasets, BayesianPredictiveDatasets) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_result.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_result.py deleted file mode 100644 index e1d985385..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_result.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_result/.""" - - -def test_bayesian_result_factory_create(): - from easydiffraction.analysis.categories.bayesian_result.default import BayesianResult - from easydiffraction.analysis.categories.bayesian_result.factory import BayesianResultFactory - - result = BayesianResultFactory.create('default') - - assert BayesianResultFactory.default_tag() == 'default' - assert isinstance(result, BayesianResult) diff --git a/tests/unit/easydiffraction/analysis/categories/test_bayesian_sampler.py b/tests/unit/easydiffraction/analysis/categories/test_bayesian_sampler.py deleted file mode 100644 index 976341765..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_bayesian_sampler.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/bayesian_sampler/.""" - - -def test_bayesian_sampler_factory_create(): - from easydiffraction.analysis.categories.bayesian_sampler.default import BayesianSampler - from easydiffraction.analysis.categories.bayesian_sampler.factory import BayesianSamplerFactory - - sampler = BayesianSamplerFactory.create('default') - - assert BayesianSamplerFactory.default_tag() == 'default' - assert isinstance(sampler, BayesianSampler) diff --git a/tests/unit/easydiffraction/analysis/categories/test_deterministic_result.py b/tests/unit/easydiffraction/analysis/categories/test_deterministic_result.py deleted file mode 100644 index ed764146d..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_deterministic_result.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for analysis/categories/deterministic_result/.""" - - -def test_deterministic_result_factory_create(): - from easydiffraction.analysis.categories.deterministic_result.default import ( - DeterministicResult, - ) - from easydiffraction.analysis.categories.deterministic_result.factory import ( - DeterministicResultFactory, - ) - - result = DeterministicResultFactory.create('default') - - assert DeterministicResultFactory.default_tag() == 'default' - assert isinstance(result, DeterministicResult) diff --git a/tests/unit/easydiffraction/analysis/categories/test_fit.py b/tests/unit/easydiffraction/analysis/categories/test_fit.py deleted file mode 100644 index 8cbc25e12..000000000 --- a/tests/unit/easydiffraction/analysis/categories/test_fit.py +++ /dev/null @@ -1,85 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for the fitting category.""" - - -def test_module_import(): - import easydiffraction.analysis.categories.fitting as MUT - - expected_module_name = 'easydiffraction.analysis.categories.fitting' - actual_module_name = MUT.__name__ - assert expected_module_name == actual_module_name - - -class TestFitModeEnum: - def test_members(self): - from easydiffraction.analysis.enums import FitModeEnum - - assert FitModeEnum.SINGLE == 'single' - assert FitModeEnum.JOINT == 'joint' - assert FitModeEnum.SEQUENTIAL == 'sequential' - - def test_default(self): - from easydiffraction.analysis.enums import FitModeEnum - - assert FitModeEnum.default() is FitModeEnum.SINGLE - - def test_descriptions(self): - from easydiffraction.analysis.enums import FitModeEnum - - for member in FitModeEnum: - desc = member.description() - assert isinstance(desc, str) - assert len(desc) > 0 - - -class TestFittingFactory: - def test_supported_tags(self): - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - tags = FittingFactory.supported_tags() - assert 'default' in tags - - def test_default_tag(self): - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - assert FittingFactory.default_tag() == 'default' - - def test_create(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - from easydiffraction.analysis.categories.fitting.factory import FittingFactory - - obj = FittingFactory.create('default') - assert isinstance(obj, Fitting) - - -class TestFitting: - def test_instantiation(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - assert fitting is not None - - def test_type_info(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - - assert Fitting.type_info.tag == 'default' - - def test_identity_category_code(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - assert fitting._identity.category_code == 'fitting' - - def test_minimizer_default(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - assert fitting.minimizer_type.value == 'lmfit (leastsq)' - - def test_minimizer_type_setter(self): - from easydiffraction.analysis.categories.fitting.default import Fitting - - fitting = Fitting() - fitting.minimizer_type = 'lmfit' - assert fitting.minimizer_type.value == 'lmfit' diff --git a/tests/unit/easydiffraction/analysis/categories/test_fit_state.py b/tests/unit/easydiffraction/analysis/categories/test_fit_state.py index 236c798a5..a26d4435b 100644 --- a/tests/unit/easydiffraction/analysis/categories/test_fit_state.py +++ b/tests/unit/easydiffraction/analysis/categories/test_fit_state.py @@ -102,106 +102,87 @@ def test_fit_parameter_correlations_rebuild_index_from_cif(): assert correlations['2'].correlation.value == 0.42 -def test_bayesian_cache_manifest_collections_serialize_expected_keys(): - from easydiffraction.analysis.categories.bayesian_distribution_caches.default import ( - BayesianDistributionCaches, - ) - from easydiffraction.analysis.categories.bayesian_pair_caches.default import ( - BayesianPairCachePaths, - BayesianPairCaches, - ) - from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasetPaths, - BayesianPredictiveDatasets, - ) +def test_fit_parameter_posterior_summary_serializes_expected_tags(): + from easydiffraction.analysis.categories.fit_parameters.default import FitParameters + from easydiffraction.core.posterior import PosteriorParameterSummary - distributions = BayesianDistributionCaches() - distributions.create( + collection = FitParameters() + collection.create( param_unique_name='lbco.cell.length_a', - x_path='/posterior/distribution/0/x', - density_path='/posterior/distribution/0/density', - n_grid=256, - n_draws_cached=48000, - ) - pairs = BayesianPairCaches() - pairs.create( - parameter_names=('z.param', 'a.param'), - paths=BayesianPairCachePaths( - x_path='/posterior/pairs/0/x', - y_path='/posterior/pairs/0/y', - density_path='/posterior/pairs/0/density', - contour_level_path='/posterior/pairs/0/contour_levels', - ), - grid_shape=(64, 64), - n_draws_cached=4000, - id='7', + fit_min=3.88, + fit_max=3.90, ) - predictive = BayesianPredictiveDatasets() - predictive.create( - experiment_name='hrpt', - x_axis_name='two_theta', - paths=BayesianPredictiveDatasetPaths( - x_path='/predictive/hrpt/x', - best_sample_prediction_path='/predictive/hrpt/best_sample_prediction', - lower_95_path='/predictive/hrpt/lower_95', - upper_95_path='/predictive/hrpt/upper_95', - ), - n_x=2500, - n_draws_cached=0, + collection.set_posterior_summary( + PosteriorParameterSummary( + unique_name='lbco.cell.length_a', + display_name='length_a', + best_sample_value=3.89, + median=3.885, + standard_deviation=0.004, + interval_68=(3.881, 3.889), + interval_95=(3.877, 3.893), + ess_bulk=120, + r_hat=1.01, + ) ) - assert '_bayesian_distribution_cache.param_unique_name' in distributions.as_cif - assert pairs['7'].param_unique_name_x.value == 'a.param' - assert pairs['7'].param_unique_name_y.value == 'z.param' - assert '_bayesian_predictive_dataset.experiment_name' in predictive.as_cif + cif_text = collection.as_cif + assert '_fit_parameter.posterior_best_sample_value' in cif_text + assert '_fit_parameter.posterior_effective_sample_size_bulk' in cif_text + summary = collection['lbco.cell.length_a'].posterior_summary( + display_name='length_a', + ) + assert summary is not None + assert summary.best_sample_value == 3.89 + assert summary.ess_bulk == 120 -def test_bayesian_sampler_and_convergence_use_integer_fields_in_cif(): - from easydiffraction.analysis.categories.bayesian_convergence.default import ( - BayesianConvergence, + +def test_dream_minimizer_sampler_and_diagnostics_use_cif_fields(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, ) - from easydiffraction.analysis.categories.bayesian_sampler.default import BayesianSampler - sampler = BayesianSampler() - sampler._set_steps(100) - sampler._set_burn(20) - sampler._set_parallel(0) - sampler._set_random_seed(123) + minimizer = BumpsDreamMinimizer() + minimizer.sampling_steps = 100 + minimizer.burn_in_steps = 20 + minimizer.parallel_workers = 0 + minimizer.random_seed = 123 + minimizer._set_gelman_rubin_max(1.01) + minimizer._set_effective_sample_size_min(80) - convergence = BayesianConvergence() - convergence._set_n_draws(80) - convergence._set_n_chains(4) - convergence._set_n_parameters(3) + cif_text = minimizer.as_cif - assert '_bayesian_sampler.steps 100' in sampler.as_cif - assert '_bayesian_sampler.parallel 0' in sampler.as_cif - assert '_bayesian_convergence.n_draws 80' in convergence.as_cif + assert '_minimizer.sampling_steps 100' in cif_text + assert '_minimizer.parallel_workers 0' in cif_text + assert '_minimizer.random_seed 123' in cif_text + assert '_minimizer.effective_sample_size_min' in cif_text -def test_bayesian_parameter_posteriors_preserve_row_order_from_cif(): - from easydiffraction.analysis.categories.bayesian_parameter_posteriors.default import ( - BayesianParameterPosteriors, - ) +def test_fit_parameter_posteriors_preserve_row_order_from_cif(): + from easydiffraction.analysis.categories.fit_parameters.default import FitParameters cif_text = """data_fit_state loop_ -_bayesian_parameter_posterior.unique_name -_bayesian_parameter_posterior.display_name -_bayesian_parameter_posterior.best_sample_value -_bayesian_parameter_posterior.median -_bayesian_parameter_posterior.uncertainty -_bayesian_parameter_posterior.interval_68_lower -_bayesian_parameter_posterior.interval_68_upper -_bayesian_parameter_posterior.interval_95_lower -_bayesian_parameter_posterior.interval_95_upper -_bayesian_parameter_posterior.ess_bulk -_bayesian_parameter_posterior.r_hat -second.param second 2.0 2.1 0.2 1.9 2.3 1.8 2.4 20 1.01 -first.param first 1.0 1.1 0.1 0.9 1.3 0.8 1.4 10 1.00 +_fit_parameter.param_unique_name +_fit_parameter.posterior_best_sample_value +_fit_parameter.posterior_median +_fit_parameter.posterior_uncertainty +_fit_parameter.posterior_interval_68_low +_fit_parameter.posterior_interval_68_high +_fit_parameter.posterior_interval_95_low +_fit_parameter.posterior_interval_95_high +_fit_parameter.posterior_effective_sample_size_bulk +_fit_parameter.posterior_gelman_rubin +second.param 2.0 2.1 0.2 1.9 2.3 1.8 2.4 20 1.01 +first.param 1.0 1.1 0.1 0.9 1.3 0.8 1.4 10 1.00 """ document = gemmi.cif.read_string(cif_text) - posteriors = BayesianParameterPosteriors() + posteriors = FitParameters() posteriors.from_cif(document.sole_block()) - assert [row.unique_name.value for row in posteriors] == ['second.param', 'first.param'] + assert [row.param_unique_name.value for row in posteriors] == [ + 'second.param', + 'first.param', + ] diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 2af863797..9d7e5b94d 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -29,11 +29,11 @@ class P: return P() -def test_show_minimizer_types_prints(capsys): +def test_minimizer_show_supported_prints(capsys): from easydiffraction.analysis.analysis import Analysis a = Analysis(project=_make_project_with_names([])) - a.fitting.show_minimizer_types() + a.minimizer.show_supported() out = capsys.readouterr().out assert 'Minimizer types' in out assert 'lmfit (leastsq)' in out @@ -45,29 +45,125 @@ def test_fit_mode_category_and_joint_fit(monkeypatch, capsys): a = Analysis(project=_make_project_with_names(['e1', 'e2'])) # Default fit mode is 'single' - assert a.fitting_mode_type == 'single' + assert a.fitting_mode.type == 'single' # Switch to joint - a.fitting_mode_type = 'joint' - assert a.fitting_mode_type == 'joint' + a.fitting_mode.type = 'joint' + assert a.fitting_mode.type == 'joint' # joint_fit exists but is empty until fit() populates it assert len(a.joint_fit) == 0 +def test_restore_raises_when_bayesian_result_kind_with_lsq_minimizer(): + """Restoring a Bayesian projection onto an LSQ minimizer must raise. + + See minimizer-category-consolidation_review-8 finding F5: a CIF + where ``_fit_result.result_kind = bayesian`` but + ``_minimizer.type = lmfit (leastsq)`` would previously + crash with ``AttributeError: 'LmfitLeastsqMinimizer' object has no + attribute 'point_estimate_name'`` deep inside the restore path. + We now raise a clear ``ValueError`` at the gate. + """ + import pytest + + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.enums import FitResultKindEnum + + a = Analysis(project=_make_project_with_names([])) + a.minimizer.type = 'lmfit (leastsq)' + a.fit_result._set_result_kind(FitResultKindEnum.BAYESIAN.value) + a._set_has_persisted_fit_state(value=True) + + with pytest.raises( + ValueError, + match=r"_minimizer\.type = 'lmfit \(leastsq\)'", + ) as excinfo: + a._restore_fit_results_from_projection() + + message = str(excinfo.value) + assert 'Bayesian' in message + assert FitResultKindEnum.BAYESIAN.value in message + + +def test_minimizer_selector_swap_warns_for_different_defaults(monkeypatch): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + a = Analysis(project=_make_project_with_names([])) + warnings: list[str] = [] + monkeypatch.setattr(analysis_mod.log, 'warning', warnings.append) + + a.minimizer.type = 'bumps (dream)' + + assert a.minimizer.type == 'bumps (dream)' + # Inter-family swap should split warnings into "removed"/"added" + # lines rather than emitting "" sentinels per + # finding F3. + assert any('removes these settings' in w and 'max_iterations' in w for w in warnings) + assert any( + 'adds these settings with defaults' in w and 'sampling_steps' in w for w in warnings + ) + assert not any('' in w for w in warnings) + + +def test_minimizer_type_invalid_assignment_raises_and_preserves_state(): + import pytest + + from easydiffraction.analysis.analysis import Analysis + + a = Analysis(project=_make_project_with_names([])) + initial_type = a.minimizer.type + + with pytest.raises(ValueError, match='Unsupported minimizer type'): + a.minimizer.type = 'bogus-minimizer' + + assert a.minimizer.type == initial_type + + +def test_fitting_mode_type_invalid_assignment_raises_and_preserves_state(): + import pytest + + from easydiffraction.analysis.analysis import Analysis + + a = Analysis(project=_make_project_with_names([])) + initial_type = a.fitting_mode.type + + with pytest.raises(ValueError, match='Unsupported fitting mode'): + a.fitting_mode.type = 'bogus-mode' + + assert a.fitting_mode.type == initial_type + + +def test_cif_restore_path_tolerates_invalid_minimizer_type(monkeypatch): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + a = Analysis(project=_make_project_with_names([])) + initial_type = a.minimizer.type + warnings: list[str] = [] + monkeypatch.setattr(analysis_mod.log, 'warning', warnings.append) + + # CIF-restore path uses strict=False so bad data warns instead of + # raising — kept tolerant for partially-broken saved projects. + a._set_minimizer_type('bogus-minimizer') + + assert a.minimizer.type == initial_type + assert any('Unsupported minimizer type' in w for w in warnings) + + def test_analysis_help(capsys): from easydiffraction.analysis.analysis import Analysis a = Analysis(project=_make_project_with_names([])) a.help() out = capsys.readouterr().out - assert "Help for 'Analysis'" in out assert 'fit' in out assert 'display' in out assert 'Properties' in out assert 'Methods' in out assert 'fit()' in out - assert 'show_fitting_mode_types()' in out + assert 'fitting_mode' in out def test_analysis_display_help(capsys): @@ -76,7 +172,6 @@ def test_analysis_display_help(capsys): a = Analysis(project=_make_project_with_names([])) a.display.help() out = capsys.readouterr().out - assert "Help for 'AnalysisDisplay'" in out assert 'all_params()' in out assert 'fit_results()' in out assert 'how_to_access_parameters()' in out @@ -281,9 +376,9 @@ def fake_fit_sequential( analysis._run_sequential() - assert analysis.fitting_mode_type == 'sequential' + assert analysis.fitting_mode.type == 'sequential' analysis_cif = analysis.as_cif - assert '_fitting.mode_type sequential' in analysis_cif + assert '_fitting_mode.type sequential' in analysis_cif assert '_sequential_fit.data_dir scans' in analysis_cif assert '_sequential_fit.file_pattern *.xye' in analysis_cif assert calls == [ diff --git a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py index 8a741fef7..1c52735fe 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py +++ b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py @@ -134,12 +134,12 @@ def beta(self, value): rows = _discover_property_rows(MyClass) assert len(rows) == 2 - names = [row[1] for row in rows] + names = [row[0] for row in rows] assert 'alpha' in names assert 'beta' in names # beta is writable - beta_row = next(r for r in rows if r[1] == 'beta') - assert beta_row[2] == '✓' + beta_row = next(r for r in rows if r[0] == 'beta') + assert beta_row[1] == '✓' def test_discover_method_rows(self): from easydiffraction.analysis.analysis import _discover_method_rows @@ -157,14 +157,14 @@ def prop(self): return 1 rows = _discover_method_rows(MyClass) - names = [row[1] for row in rows] + names = [row[0] for row in rows] assert 'do_thing()' in names assert '_private()' not in names assert 'prop()' not in names # ------------------------------------------------------------------ -# Analysis.minimizer_type setter +# Analysis.minimizer.type setter # ------------------------------------------------------------------ @@ -173,8 +173,8 @@ def test_setter_changes_minimizer(self, capsys): from easydiffraction.analysis.analysis import Analysis a = Analysis(project=_make_project()) - assert a.fitting.minimizer_type.value == 'lmfit (leastsq)' - a.fitting.minimizer_type = 'lmfit (leastsq)' + assert a.minimizer.type == 'lmfit (leastsq)' + a.minimizer.type = 'lmfit' out = capsys.readouterr().out assert 'Current minimizer changed to' in out @@ -261,7 +261,7 @@ def __getitem__(self, name): project = SimpleNamespace( experiments=Experiments(), structures=object(), - rendering=SimpleNamespace(plotter=Plotter()), + chart=SimpleNamespace(plotter=Plotter()), _varname='proj', ) analysis = Analysis(project=project) @@ -289,18 +289,17 @@ def __getitem__(self, name): convergence_diagnostics={}, ) - analysis._store_bayesian_result_projection(results) + analysis._store_posterior_plot_cache_projection(results) - assert analysis.bayesian_result.has_distribution_cache.value is True - assert analysis.bayesian_result.has_pair_cache.value is False - assert analysis.bayesian_result.has_posterior_predictive.value is True + sidecar = analysis._persisted_fit_state_sidecar + assert sidecar['distribution_caches'] + assert sidecar['pair_caches'] == {} + assert sidecar['predictive_datasets'] assert np.allclose( - analysis._persisted_fit_state_sidecar['distribution_caches']['alpha']['x'], + sidecar['distribution_caches']['alpha']['x'], np.asarray([0.5, 1.5], dtype=float), ) assert np.allclose( - analysis._persisted_fit_state_sidecar['predictive_datasets']['hrpt'][ - 'best_sample_prediction' - ], + sidecar['predictive_datasets']['hrpt']['best_sample_prediction'], np.asarray([3.0, 4.0], dtype=float), ) diff --git a/tests/unit/easydiffraction/analysis/test_fitting.py b/tests/unit/easydiffraction/analysis/test_fitting.py index 83272f9fa..72d2c7724 100644 --- a/tests/unit/easydiffraction/analysis/test_fitting.py +++ b/tests/unit/easydiffraction/analysis/test_fitting.py @@ -90,6 +90,9 @@ def fit(self, params, obj, verbosity=None, **kwargs): def _sync_result_to_parameters(self, params, engine_params): pass + def _finalize_timing(self): + pass + def _stop_tracking(self): return None @@ -156,6 +159,9 @@ def fit(self, params, obj, verbosity=None, **kwargs): def _stop_tracking(self): self.stop_calls += 1 + def _finalize_timing(self): + pass + analysis_events: list[str] = [] analysis = SimpleNamespace( _capture_fit_parameter_state=lambda params: analysis_events.append('capture'), diff --git a/tests/unit/easydiffraction/core/test_category.py b/tests/unit/easydiffraction/core/test_category.py index c1a4ffbe2..d7edb094e 100644 --- a/tests/unit/easydiffraction/core/test_category.py +++ b/tests/unit/easydiffraction/core/test_category.py @@ -236,7 +236,6 @@ def test_category_item_help(capsys): it.a = 'name1' it.help() out = capsys.readouterr().out - assert 'Help for' in out assert 'Parameters' in out assert 'string' in out # Type column assert '✓' in out # a and b are writable @@ -249,7 +248,6 @@ def test_category_collection_help(capsys): c.create(a='n2') c.help() out = capsys.readouterr().out - assert 'Help for' in out assert 'Items (2)' in out assert 'n1' in out assert 'n2' in out diff --git a/tests/unit/easydiffraction/core/test_datablock.py b/tests/unit/easydiffraction/core/test_datablock.py index 98d0dc252..64a2c0c29 100644 --- a/tests/unit/easydiffraction/core/test_datablock.py +++ b/tests/unit/easydiffraction/core/test_datablock.py @@ -172,7 +172,6 @@ def cat(self): b = Block() b.help() out = capsys.readouterr().out - assert 'Help for' in out assert 'Categories' in out assert 'cat' in out diff --git a/tests/unit/easydiffraction/core/test_guard.py b/tests/unit/easydiffraction/core/test_guard.py index 64d466801..7e41d947b 100644 --- a/tests/unit/easydiffraction/core/test_guard.py +++ b/tests/unit/easydiffraction/core/test_guard.py @@ -82,13 +82,11 @@ def score(self, v): obj = Obj() obj.help() out = capsys.readouterr().out - assert "Help for 'Obj'" in out assert 'name' in out assert 'score' in out assert 'Properties' in out assert 'Methods' in out assert '✓' in out # score is writable - assert '✗' in out # name is read-only def test_first_sentence_extracts_first_paragraph(): diff --git a/tests/unit/easydiffraction/core/test_posterior.py b/tests/unit/easydiffraction/core/test_posterior.py new file mode 100644 index 000000000..e03758897 --- /dev/null +++ b/tests/unit/easydiffraction/core/test_posterior.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for posterior summary value objects.""" + +from __future__ import annotations + + +def test_posterior_parameter_summary_stores_statistics_and_diagnostics(): + from easydiffraction.core.posterior import PosteriorParameterSummary + + summary = PosteriorParameterSummary( + unique_name='lbco.cell.length_a', + display_name='a', + best_sample_value=3.89, + median=3.885, + standard_deviation=0.004, + interval_68=(3.881, 3.889), + interval_95=(3.877, 3.893), + ess_bulk=120, + r_hat=1.01, + ) + + assert summary.unique_name == 'lbco.cell.length_a' + assert summary.interval_68 == (3.881, 3.889) + assert summary.ess_bulk == 120 + assert summary.r_hat == 1.01 diff --git a/tests/unit/easydiffraction/core/test_switchable.py b/tests/unit/easydiffraction/core/test_switchable.py new file mode 100644 index 000000000..127d4ccb6 --- /dev/null +++ b/tests/unit/easydiffraction/core/test_switchable.py @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from easydiffraction.core.switchable import SwitchableCategoryBase + + +class _Switchable(SwitchableCategoryBase): + _category_code = 'dummy_category' + _owner_attr_name = 'dummy' + _swap_method_name = '_swap_dummy' + + def __init__( + self, + *, + current_type: str = 'alpha', + supported: list[tuple[str, str]] | None = None, + ) -> None: + self._type = SimpleNamespace(value=current_type) + self.supported = supported or [ + ('alpha', 'Alpha type'), + ('beta', 'Beta type'), + ] + self.filters_seen: dict[str, object] | None = None + + def _supported_types( + self, + filters: dict[str, object], + ) -> list[tuple[str, str]]: + self.filters_seen = filters + return self.supported + + +class _CanonicalSwitchable(_Switchable): + def _canonicalize(self, value: str) -> str: + return value.lower() + + +class _Parent: + def __init__(self, category: _Switchable) -> None: + self.dummy = category + self.swap_calls: list[str] = [] + + def _supported_filters_for(self, category: object) -> dict[str, object]: + assert category is self.dummy + return {'scope': 'test'} + + def _swap_dummy(self, value: str) -> None: + self.swap_calls.append(value) + self.dummy._type.value = value + + +def test_type_setter_rejects_detached_category(): + category = _Switchable() + + with pytest.raises(RuntimeError, match='detached'): + category.type = 'beta' + + +def test_type_setter_rejects_stale_category_reference(): + old_category = _Switchable() + new_category = _Switchable() + parent = _Parent(new_category) + old_category._parent = parent + + with pytest.raises(RuntimeError, match='no longer the live category'): + old_category.type = 'beta' + + +def test_default_canonicalize_is_identity(): + category = _Switchable() + + assert category._canonicalize('MiXeD') == 'MiXeD' + + +def test_type_setter_delegates_canonical_value_to_owner(): + category = _CanonicalSwitchable() + parent = _Parent(category) + category._parent = parent + + category.type = 'BETA' + + assert parent.swap_calls == ['beta'] + assert category.type == 'beta' + + +@pytest.mark.parametrize( + ('supported', 'expected_title'), + [ + ([('alpha', 'Factory-backed type'), ('beta', 'Other type')], 'Dummy Category types'), + ([('auto', 'Renderer default'), ('plotly', 'Plotly renderer')], 'Dummy Category types'), + ([('single', 'Single fit'), ('joint', 'Joint fit')], 'Dummy Category types'), + ], +) +def test_show_supported_renders_active_type_for_supported_shapes( + supported, + expected_title, + monkeypatch, +): + import easydiffraction.core.switchable as switchable_mod + + category = _Switchable(current_type=supported[0][0], supported=supported) + parent = _Parent(category) + category._parent = parent + paragraphs: list[str] = [] + rendered: dict[str, object] = {} + + monkeypatch.setattr( + switchable_mod.console, + 'paragraph', + lambda text: paragraphs.append(text), + ) + monkeypatch.setattr( + switchable_mod, + 'render_table', + lambda **kwargs: rendered.update(kwargs), + ) + + category.show_supported() + + assert paragraphs == [expected_title] + assert category.filters_seen == {'scope': 'test'} + assert rendered['columns_headers'] == ['', 'Type', 'Description'] + assert rendered['columns_data'][0][0] == '*' + assert rendered['columns_data'][0][1:] == list(supported[0]) diff --git a/tests/unit/easydiffraction/core/test_variable_posterior.py b/tests/unit/easydiffraction/core/test_variable_posterior.py new file mode 100644 index 000000000..b87821abc --- /dev/null +++ b/tests/unit/easydiffraction/core/test_variable_posterior.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for posterior summaries on fittable parameters.""" + +from __future__ import annotations + + +def test_parameter_posterior_summary_is_set_internally(): + from easydiffraction.core.posterior import PosteriorParameterSummary + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + + parameter = Parameter( + name='length_a', + value_spec=AttributeSpec(default=3.88), + cif_handler=CifHandler(names=['_cell.length_a']), + ) + summary = PosteriorParameterSummary( + unique_name=parameter.unique_name, + display_name='a', + best_sample_value=3.89, + median=3.885, + standard_deviation=0.004, + interval_68=(3.881, 3.889), + interval_95=(3.877, 3.893), + ) + + assert parameter.posterior is None + + parameter._set_posterior(summary) + + assert parameter.posterior is summary + + parameter._set_posterior(None) + + assert parameter.posterior is None diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_default.py b/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_default.py deleted file mode 100644 index 518f31b92..000000000 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_default.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import gemmi - - -class _Parent: - def __init__(self, calculation): - self._calculator = None - self._set_calls = [] - self.calculation = calculation - - def _set_calculator_type(self, value: str, *, announce: bool = True) -> None: - del announce - self._set_calls.append(value) - self._calculator = object() - self.calculation._calculator_type.value = value - - def _resolve_calculation(self) -> None: - self._calculator = object() - - @staticmethod - def _supported_calculator_tags() -> list[str]: - return ['cryspy', 'crysfml'] - - -def test_calculation_defaults(): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - - calculation = Calculation(calculator_type='cryspy') - - assert calculation.type_info.tag == 'default' - assert calculation._identity.category_code == 'calculation' - assert calculation.calculator_type.value == 'cryspy' - - -def test_calculation_setter_delegates_to_parent(): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - - calculation = Calculation(calculator_type='cryspy') - parent = _Parent(calculation) - calculation._parent = parent - - calculation.calculator_type = 'crysfml' - - assert parent._set_calls == ['crysfml'] - assert calculation.calculator_type.value == 'crysfml' - - -def test_calculator_property_resolves_from_parent(): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - - calculation = Calculation(calculator_type='cryspy') - parent = _Parent(calculation) - calculation._parent = parent - - calculator = calculation.calculator - - assert calculator is parent._calculator - assert calculator is not None - - -def test_show_calculator_types_prints(capsys): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - - calculation = Calculation(calculator_type='cryspy') - parent = _Parent(calculation) - calculation._parent = parent - - calculation.show_calculator_types() - out = capsys.readouterr().out - - assert 'Calculator types' in out - assert 'cryspy' in out - - -def test_from_cif_restores_value_with_parent(): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - - calculation = Calculation(calculator_type='cryspy') - parent = _Parent(calculation) - calculation._parent = parent - block = gemmi.cif.read_string( - 'data_test\n_calculation.calculator_type crysfml\n', - ).sole_block() - - calculation.from_cif(block) - - assert parent._set_calls == ['crysfml'] - assert calculation.calculator_type.value == 'crysfml' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_factory.py deleted file mode 100644 index 00cc98af7..000000000 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/calculation/test_factory.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import pytest - - -def test_calculation_factory_default_and_create(): - from easydiffraction.datablocks.experiment.categories.calculation.default import Calculation - from easydiffraction.datablocks.experiment.categories.calculation.factory import ( - CalculationFactory, - ) - - assert CalculationFactory.default_tag() == 'default' - assert 'default' in CalculationFactory.supported_tags() - - calculation = CalculationFactory.create('default', calculator_type='cryspy') - - assert isinstance(calculation, Calculation) - - -def test_calculation_factory_rejects_unknown_tag(): - from easydiffraction.datablocks.experiment.categories.calculation.factory import ( - CalculationFactory, - ) - - with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): - CalculationFactory.create('missing', calculator_type='cryspy') diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_default.py b/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_default.py new file mode 100644 index 000000000..e2a5793e6 --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_default.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gemmi + + +class _Parent: + def __init__(self, calculator_category): + self._calculator = None + self._set_calls = [] + self.calculator = calculator_category + + def _swap_calculator( + self, + value: str, + *, + announce: bool = True, + strict: bool = True, + ) -> None: + del announce, strict + self._set_calls.append(value) + self._calculator = object() + self.calculator._type.value = value + + def _resolve_calculator(self) -> None: + self._calculator = object() + + @staticmethod + def _supported_calculator_tags() -> list[str]: + return ['cryspy', 'crysfml'] + + @staticmethod + def _supported_filters_for(category: object) -> dict[str, object]: + del category + return {} + + +def test_calculator_defaults(): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + + calculator_category = Calculator(type='cryspy') + + assert calculator_category.type_info.tag == 'default' + assert calculator_category._identity.category_code == 'calculator' + assert calculator_category.type == 'cryspy' + + +def test_calculator_selector_delegates_to_parent(): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + + calculator_category = Calculator(type='cryspy') + parent = _Parent(calculator_category) + calculator_category._parent = parent + + calculator_category.type = 'crysfml' + + assert parent._set_calls == ['crysfml'] + assert calculator_category.type == 'crysfml' + + +def test_calculator_property_resolves_from_parent(): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + + calculator_category = Calculator(type='cryspy') + parent = _Parent(calculator_category) + calculator_category._parent = parent + + calculator = calculator_category.calculator + + assert calculator is parent._calculator + assert calculator is not None + + +def test_show_supported_calculators_prints(capsys): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + + calculator_category = Calculator(type='cryspy') + parent = _Parent(calculator_category) + calculator_category._parent = parent + + calculator_category.show_supported() + out = capsys.readouterr().out + + assert 'Calculator types' in out + assert 'cryspy' in out + + +def test_from_cif_restores_value_with_parent(): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + + calculator_category = Calculator(type='cryspy') + parent = _Parent(calculator_category) + calculator_category._parent = parent + block = gemmi.cif.read_string( + 'data_test\n_calculator.type crysfml\n', + ).sole_block() + + calculator_category.from_cif(block) + + assert parent._set_calls == ['crysfml'] + assert calculator_category.type == 'crysfml' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_factory.py new file mode 100644 index 000000000..6370055c8 --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/calculator/test_factory.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_calculator_factory_default_and_create(): + from easydiffraction.datablocks.experiment.categories.calculator.default import Calculator + from easydiffraction.datablocks.experiment.categories.calculator.factory import ( + CalculatorCategoryFactory, + ) + + assert CalculatorCategoryFactory.default_tag() == 'default' + assert 'default' in CalculatorCategoryFactory.supported_tags() + + calculator_category = CalculatorCategoryFactory.create('default', type='cryspy') + + assert isinstance(calculator_category, Calculator) + + +def test_calculator_factory_rejects_unknown_tag(): + from easydiffraction.datablocks.experiment.categories.calculator.factory import ( + CalculatorCategoryFactory, + ) + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + CalculatorCategoryFactory.create('missing', type='cryspy') diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py index 5c0b89d52..5dc128272 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_base.py @@ -21,8 +21,8 @@ def __init__(self): super().__init__() p = DummyPeak() - assert isinstance(p.profile_type, StringDescriptor) - assert p.profile_type.value == '' + assert isinstance(p._type, StringDescriptor) + assert p.type == '' def test_peak_base_profile_type_reflects_type_info_tag(): @@ -33,7 +33,7 @@ def __init__(self): super().__init__() p = TaggedPeak() - assert p.profile_type.value == 'my-profile' + assert p.type == 'my-profile' def test_peak_base_profile_type_in_parameters(): @@ -45,4 +45,4 @@ def __init__(self): p = TaggedPeak() param_names = {param.name for param in p.parameters} - assert 'profile_type' in param_names + assert 'type' in param_names diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py index d18be2565..185a21f30 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py @@ -30,12 +30,14 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: ex = ConcretePd(name='ex1', type=et) # valid switch using tag string - ex.peak_profile_type = 'pseudo-voigt' - assert ex.peak_profile_type == 'pseudo-voigt' - # invalid string should warn and keep previous - ex.peak_profile_type = 'non-existent' - captured = capsys.readouterr().out - assert 'Unsupported' in captured or 'Unknown' in captured + import pytest + + ex.peak.type = 'pseudo-voigt' + assert ex.peak.type == 'cwl-pseudo-voigt' + # invalid string should raise and keep previous + with pytest.raises(ValueError, match='Unsupported peak profile'): + ex.peak.type = 'non-existent' + assert ex.peak.type == 'cwl-pseudo-voigt' def test_pd_experiment_set_peak_profile_type_silent(capsys): @@ -61,7 +63,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: ex._set_peak_profile_type('pseudo-voigt + empirical asymmetry') # Profile type was switched - assert ex.peak_profile_type == 'pseudo-voigt + empirical asymmetry' + assert ex.peak.type == 'cwl-pseudo-voigt-empirical-asymmetry' assert ex.peak.__class__.__name__ == 'CwlPseudoVoigtEmpiricalAsymmetry' # No console output was emitted @@ -89,15 +91,15 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: et._set_scattering_type(ScatteringTypeEnum.BRAGG.value) ex = ConcretePd(name='ex1', type=et) - original_type = ex.peak_profile_type + original_type = ex.peak.type ex._set_peak_profile_type('nonexistent-profile') # Profile type unchanged - assert ex.peak_profile_type == original_type + assert ex.peak.type == original_type def test_pd_experiment_restore_switchable_types_switches_peak(): - """_restore_switchable_types reads _peak.profile_type from a CIF block.""" + """_restore_switchable_types reads _peak.type from a CIF block.""" import gemmi from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType @@ -119,13 +121,13 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: ex = ConcretePd(name='ex1', type=et) - cif = 'data_ex1\n_peak.profile_type "pseudo-voigt + empirical asymmetry"\n' + cif = 'data_ex1\n_peak.type "pseudo-voigt + empirical asymmetry"\n' doc = gemmi.cif.read_string(cif) block = doc.sole_block() ex._restore_switchable_types(block) - assert ex.peak_profile_type == 'pseudo-voigt + empirical asymmetry' + assert ex.peak.type == 'cwl-pseudo-voigt-empirical-asymmetry' assert ex.peak.__class__.__name__ == 'CwlPseudoVoigtEmpiricalAsymmetry' @@ -152,7 +154,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: ex = ConcreteBase(name='ex1', type=et) - cif = 'data_ex1\n_peak.profile_type "pseudo-voigt + empirical asymmetry"\n' + cif = 'data_ex1\n_peak.type "pseudo-voigt + empirical asymmetry"\n' doc = gemmi.cif.read_string(cif) block = doc.sole_block() diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py index ca5aa0637..92b399504 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py @@ -66,32 +66,35 @@ class TestExperimentBaseCalculator: def test_calculator_auto_resolves(self): ex = ConcreteBase(name='ex1', type=_mk_type_powder_cwl_bragg()) # calculator should auto-resolve on first access - assert ex.calculation.calculator is not None + assert ex.calculator.calculator is not None def test_calculator_type_auto_resolves(self): ex = ConcreteBase(name='ex1', type=_mk_type_powder_cwl_bragg()) - ct = ex.calculation.calculator_type.value + ct = ex.calculator.type assert isinstance(ct, str) assert len(ct) > 0 def test_calculator_type_invalid(self): + import pytest + ex = ConcreteBase(name='ex1', type=_mk_type_powder_cwl_bragg()) - _ = ex.calculation.calculator_type.value # trigger resolve - old = ex.calculation.calculator_type.value - ex.calculation.calculator_type = 'bogus-engine' - assert ex.calculation.calculator_type.value == old + _ = ex.calculator.calculator # trigger resolve + old = ex.calculator.type + with pytest.raises(ValueError, match='Unsupported calculator'): + ex.calculator.type = 'bogus-engine' + assert ex.calculator.type == old def test_show_calculator_types(self, capsys): ex = ConcreteBase(name='ex1', type=_mk_type_powder_cwl_bragg()) - ex.calculation.show_calculator_types() + ex.calculator.show_supported() out = capsys.readouterr().out assert len(out) > 0 def test_show_calculator_types_includes_current(self, capsys): ex = ConcreteBase(name='ex1', type=_mk_type_powder_cwl_bragg()) - ex.calculation.show_calculator_types() + ex.calculator.show_supported() out = capsys.readouterr().out - assert ex.calculation.calculator_type.value in out + assert ex.calculator.type in out class TestExperimentBaseAsCif: @@ -134,16 +137,16 @@ class TestPdExperimentPeak: def test_peak_defaults(self): ex = ConcretePd(name='pd1', type=_mk_type_powder_cwl_bragg()) assert ex.peak is not None - assert ex.peak_profile_type is not None + assert ex.peak.type is not None def test_show_peak_profile_types(self, capsys): ex = ConcretePd(name='pd1', type=_mk_type_powder_cwl_bragg()) - ex.show_peak_profile_types() + ex.peak.show_supported() out = capsys.readouterr().out assert len(out) > 0 def test_show_peak_profile_types_includes_current(self, capsys): ex = ConcretePd(name='pd1', type=_mk_type_powder_cwl_bragg()) - ex.show_peak_profile_types() + ex.peak.show_supported() out = capsys.readouterr().out - assert str(ex.peak_profile_type) in out + assert str(ex.peak.type) in out diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py index 2c8d17a95..b43644b81 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py @@ -42,15 +42,18 @@ def _mk_type_powder_tof_bragg(): def test_background_defaults_and_change(): expt = BraggPdExperiment(name='e1', type=_mk_type_powder_cwl_bragg()) # default background type - assert expt.background_type == BackgroundFactory.default_tag() + assert expt.background.type == BackgroundFactory.default_tag() # change to a supported type - expt.background_type = 'chebyshev' - assert expt.background_type == 'chebyshev' + expt.background.type = 'chebyshev' + assert expt.background.type == 'chebyshev' - # unknown type keeps previous type and prints warnings (no raise) - expt.background_type = 'not-a-type' # invalid string - assert expt.background_type == 'chebyshev' + # unknown type raises and keeps previous value + import pytest + + with pytest.raises(ValueError, match='Unsupported background type'): + expt.background.type = 'not-a-type' + assert expt.background.type == 'chebyshev' def test_load_ascii_data_rounds_and_defaults_sy(tmp_path: pytest.TempPathFactory): @@ -103,12 +106,10 @@ def test_bragg_pd_experiment_disables_refln_for_crysfml_and_restores_it_for_crys assert isinstance(experiment.refln, PowderCwlReflnData) - experiment._calculator_type = CalculatorEnum.CRYSFML.value - experiment._sync_refln_category() + experiment.calculator.type = CalculatorEnum.CRYSFML.value assert experiment.refln is None - experiment._calculator_type = CalculatorEnum.CRYSPY.value - experiment._sync_refln_category() + experiment.calculator.type = CalculatorEnum.CRYSPY.value assert isinstance(experiment.refln, PowderCwlReflnData) diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py index 9a196b67c..a8eeaa2a9 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py @@ -69,7 +69,7 @@ def test_switchable_categories(self): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) # extinction assert ex.extinction is not None - assert isinstance(ex.extinction_type, str) + assert isinstance(ex.extinction.type, str) # linked crystal assert ex.linked_crystal is not None # instrument @@ -78,22 +78,25 @@ def test_switchable_categories(self): assert ex.refln is not None def test_extinction_type_invalid(self): + import pytest + ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) - old = ex.extinction_type - ex.extinction_type = 'bogus' - assert ex.extinction_type == old + old = ex.extinction.type + with pytest.raises(ValueError, match='Unsupported extinction type'): + ex.extinction.type = 'bogus' + assert ex.extinction.type == old def test_show_extinction_types(self, capsys): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) - ex.show_extinction_types() + ex.extinction.show_supported() out = capsys.readouterr().out assert len(out) > 0 def test_show_extinction_types_includes_current(self, capsys): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) - ex.show_extinction_types() + ex.extinction.show_supported() out = capsys.readouterr().out - assert ex.extinction_type in out + assert ex.extinction.type in out class TestTofScExperiment: diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py index 55d639682..d450e942e 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_factory.py @@ -40,10 +40,10 @@ def test_from_cif_str_restores_non_default_peak_profile_type(): name='test', sample_form='powder', beam_mode='constant wavelength', - radiation_probe='x-ray', + radiation_probe='xray', scattering_type='bragg', ) - expt.peak_profile_type = 'pseudo-voigt + empirical asymmetry' + expt.peak.type = 'pseudo-voigt + empirical asymmetry' expt.peak.asym_empir_1 = -0.005 expt.peak.asym_empir_2 = 0.067 expt.peak.broad_gauss_u = 0.039 @@ -52,7 +52,7 @@ def test_from_cif_str_restores_non_default_peak_profile_type(): loaded = ExperimentFactory.from_cif_str(cif_str) - assert loaded.peak_profile_type == 'pseudo-voigt + empirical asymmetry' + assert loaded.peak.type == 'cwl-pseudo-voigt-empirical-asymmetry' assert loaded.peak.__class__.__name__ == 'CwlPseudoVoigtEmpiricalAsymmetry' assert abs(loaded.peak.asym_empir_1.value - (-0.005)) < 1e-6 assert abs(loaded.peak.asym_empir_2.value - 0.067) < 1e-6 diff --git a/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py b/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py index 7d45dfbd3..465fd76ae 100644 --- a/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py +++ b/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py @@ -31,9 +31,9 @@ def test_real_analysis_as_cif_is_singleton_section_without_data_header() -> None analysis_cif = project.analysis.as_cif - assert analysis_cif.startswith('_fitting.mode_type single') + assert analysis_cif.startswith('_fitting_mode.type single') assert not analysis_cif.startswith('data_') - assert '_fitting.minimizer_type' in analysis_cif + assert '_minimizer.type' in analysis_cif assert '_joint_fit.experiment_id' not in analysis_cif assert '_sequential_fit.data_dir' not in analysis_cif assert '_sequential_fit_extract.id' not in analysis_cif @@ -66,7 +66,7 @@ def test_real_analysis_as_cif_includes_joint_fit_only_in_joint_mode() -> None: analysis_cif = analysis.as_cif assert not analysis_cif.startswith('data_') - assert '_fitting.mode_type joint' in analysis_cif + assert '_fitting_mode.type joint' in analysis_cif assert '_joint_fit.experiment_id' in analysis_cif assert '_joint_fit.weight' in analysis_cif assert '_sequential_fit.data_dir' not in analysis_cif @@ -89,7 +89,7 @@ def test_real_analysis_as_cif_includes_sequential_sections_only_in_sequential_mo analysis_cif = analysis.as_cif assert not analysis_cif.startswith('data_') - assert '_fitting.mode_type sequential' in analysis_cif + assert '_fitting_mode.type sequential' in analysis_cif assert '_sequential_fit.data_dir scans' in analysis_cif assert '_sequential_fit.file_pattern *.xye' in analysis_cif assert '_sequential_fit_extract.id' in analysis_cif diff --git a/tests/unit/easydiffraction/io/cif/test_serialize_more.py b/tests/unit/easydiffraction/io/cif/test_serialize_more.py index 3c9db1bc1..1820bc9e7 100644 --- a/tests/unit/easydiffraction/io/cif/test_serialize_more.py +++ b/tests/unit/easydiffraction/io/cif/test_serialize_more.py @@ -175,7 +175,7 @@ def as_cif(self): assert out_without.endswith('1') -def test_analysis_to_cif_renders_all_sections(): +def test_analysis_to_cif_renders_all_sections(monkeypatch): import easydiffraction.io.cif.serialize as MUT class Obj: @@ -187,16 +187,23 @@ def as_cif(self): return self._t class A: - fitting_mode_type = 'single' - fitting = Obj('_fitting.minimizer_type lmfit') + minimizer = Obj('_minimizer.type lmfit') aliases = Obj('ALIASES') constraints = Obj('CONSTRAINTS') + monkeypatch.setattr( + MUT, + 'category_owner_to_cif', + lambda analysis: ( + f'{analysis.minimizer.as_cif}\n\n' + f'{analysis.aliases.as_cif}\n\n' + f'{analysis.constraints.as_cif}' + ), + ) + out = MUT.analysis_to_cif(A()) lines = [line for line in out.splitlines() if line] - assert lines[0].startswith('_fitting.mode_type') - assert 'single' in lines[0] - assert lines[1].startswith('_fitting.minimizer_type') - assert 'lmfit' in lines[1] + assert lines[0].startswith('_minimizer.type') + assert 'lmfit' in lines[0] assert 'ALIASES' in out assert 'CONSTRAINTS' in out diff --git a/tests/unit/easydiffraction/io/test_results_sidecar.py b/tests/unit/easydiffraction/io/test_results_sidecar.py index 556e21e5a..70dbef7d6 100644 --- a/tests/unit/easydiffraction/io/test_results_sidecar.py +++ b/tests/unit/easydiffraction/io/test_results_sidecar.py @@ -10,56 +10,68 @@ import numpy as np -def _analysis_with_predictive_sidecar() -> object: - from easydiffraction.analysis.categories.bayesian_predictive_datasets.default import ( - BayesianPredictiveDatasetPaths, - BayesianPredictiveDatasets, - ) - from easydiffraction.analysis.categories.bayesian_result.default import BayesianResult +def _analysis_with_sidecar_payload( + *, + include_posterior: bool = True, + include_distribution: bool = True, + include_pair: bool = True, + include_predictive: bool = True, +) -> object: from easydiffraction.analysis.categories.fit_result.default import FitResult fit_result = FitResult() fit_result._set_result_kind('bayesian') - bayesian_result = BayesianResult() - bayesian_result._set_has_posterior_predictive(value=True) - predictive = BayesianPredictiveDatasets() - predictive.create( - experiment_name='hrpt', - x_axis_name='two_theta', - paths=BayesianPredictiveDatasetPaths( - x_path='/predictive/hrpt/x', - best_sample_prediction_path='/predictive/hrpt/best_sample_prediction', - lower_95_path='/predictive/hrpt/lower_95', - upper_95_path='/predictive/hrpt/upper_95', - ), - n_x=2, - n_draws_cached=0, - ) + + posterior_samples = None + if include_posterior: + posterior_samples = SimpleNamespace( + parameter_samples=np.asarray([[[1.0]], [[1.2]]], dtype=float), + log_posterior=np.asarray([[-4.0], [-3.5]], dtype=float), + draw_index=np.asarray([0, 1]), + ) + + distribution_caches = {} + if include_distribution: + distribution_caches = { + 'alpha': { + 'x': np.asarray([0.5, 1.5], dtype=float), + 'density': np.asarray([0.25, 0.75], dtype=float), + } + } + + pair_caches = {} + if include_pair: + pair_caches = { + 'alpha__beta': { + 'x': np.asarray([0.5, 1.5], dtype=float), + 'y': np.asarray([2.5, 3.5], dtype=float), + 'density': np.asarray([[0.1, 0.2], [0.3, 0.4]], dtype=float), + } + } + + posterior_predictive = {} + if include_predictive: + posterior_predictive = { + 'hrpt': SimpleNamespace( + experiment_name='hrpt', + x_axis_name='two_theta', + x=np.asarray([1.0, 2.0]), + best_sample_prediction=np.asarray([3.0, 4.0]), + lower_95=np.asarray([2.5, 3.5]), + upper_95=np.asarray([3.5, 4.5]), + lower_68=None, + upper_68=None, + draws=None, + ) + } + return SimpleNamespace( fit_result=fit_result, - bayesian_result=bayesian_result, - bayesian_convergence=SimpleNamespace( - n_draws=SimpleNamespace(value=0), - n_chains=SimpleNamespace(value=0), - n_parameters=SimpleNamespace(value=0), - ), - bayesian_distribution_caches=[], - bayesian_pair_caches=[], - bayesian_predictive_datasets=predictive, fit_results=SimpleNamespace( - posterior_predictive={ - 'hrpt': SimpleNamespace( - experiment_name='hrpt', - x_axis_name='two_theta', - x=np.asarray([1.0, 2.0]), - best_sample_prediction=np.asarray([3.0, 4.0]), - lower_95=np.asarray([2.5, 3.5]), - upper_95=np.asarray([3.5, 4.5]), - lower_68=None, - upper_68=None, - draws=None, - ) - } + posterior_samples=posterior_samples, + posterior_distribution_caches=distribution_caches, + posterior_pair_caches=pair_caches, + posterior_predictive=posterior_predictive, ), _persisted_fit_state_sidecar={}, _has_persisted_fit_state=lambda: True, @@ -71,17 +83,34 @@ def test_write_and_read_analysis_results_sidecar_round_trip_predictive(tmp_path) from easydiffraction.io.results_sidecar import write_analysis_results_sidecar analysis_dir = Path(tmp_path) - analysis = _analysis_with_predictive_sidecar() + analysis = _analysis_with_sidecar_payload() write_analysis_results_sidecar(analysis=analysis, analysis_dir=analysis_dir) sidecar_path = analysis_dir / 'results.h5' assert sidecar_path.is_file() - restored = _analysis_with_predictive_sidecar() + import h5py + + with h5py.File(sidecar_path, 'r') as handle: + assert 'posterior' in handle + assert 'distribution_cache' in handle + assert 'pair_cache' in handle + assert 'predictive' in handle + + restored = _analysis_with_sidecar_payload() restored.fit_results = None read_analysis_results_sidecar(analysis=restored, analysis_dir=analysis_dir) + assert 'posterior' in restored._persisted_fit_state_sidecar + posterior = restored._persisted_fit_state_sidecar['posterior'] + assert np.allclose(posterior['parameter_samples'], np.asarray([[[1.0]], [[1.2]]])) + assert 'distribution_caches' in restored._persisted_fit_state_sidecar + distribution = restored._persisted_fit_state_sidecar['distribution_caches']['alpha'] + assert np.allclose(distribution['x'], np.asarray([0.5, 1.5])) + assert 'pair_caches' in restored._persisted_fit_state_sidecar + pair = restored._persisted_fit_state_sidecar['pair_caches']['alpha__beta'] + assert np.allclose(pair['density'], np.asarray([[0.1, 0.2], [0.3, 0.4]])) assert 'predictive_datasets' in restored._persisted_fit_state_sidecar dataset = restored._persisted_fit_state_sidecar['predictive_datasets']['hrpt'] assert np.allclose(dataset['x'], np.asarray([1.0, 2.0])) @@ -91,7 +120,7 @@ def test_write_and_read_analysis_results_sidecar_round_trip_predictive(tmp_path) def test_read_analysis_results_sidecar_warns_when_expected_file_is_missing(tmp_path, monkeypatch): from easydiffraction.io import results_sidecar as results_sidecar_mod - analysis = _analysis_with_predictive_sidecar() + analysis = _analysis_with_sidecar_payload() warnings: list[str] = [] monkeypatch.setattr(results_sidecar_mod.log, 'warning', warnings.append) @@ -104,35 +133,45 @@ def test_read_analysis_results_sidecar_warns_when_expected_file_is_missing(tmp_p assert any('Expected Bayesian results sidecar is missing' in warning for warning in warnings) -def test_sidecar_path_traversal_falls_back_to_local_results_file(tmp_path, monkeypatch): +def test_write_analysis_results_sidecar_truncates_stale_payloads(tmp_path): from easydiffraction.io import results_sidecar as results_sidecar_mod analysis_dir = Path(tmp_path) / 'analysis' - external_sidecar = Path(tmp_path) / 'outside.h5' - analysis = _analysis_with_predictive_sidecar() - analysis.bayesian_result._set_sidecar_file('../outside.h5') - - warnings: list[str] = [] - monkeypatch.setattr(results_sidecar_mod.log, 'warning', warnings.append) - + analysis = _analysis_with_sidecar_payload() results_sidecar_mod.write_analysis_results_sidecar( analysis=analysis, analysis_dir=analysis_dir, ) - assert (analysis_dir / 'results.h5').is_file() - assert not external_sidecar.exists() - - restored = _analysis_with_predictive_sidecar() - restored.fit_results = None - restored.bayesian_result._set_sidecar_file('../outside.h5') - results_sidecar_mod.read_analysis_results_sidecar( - analysis=restored, + analysis = _analysis_with_sidecar_payload( + include_posterior=False, + include_distribution=False, + include_pair=False, + ) + results_sidecar_mod.write_analysis_results_sidecar( + analysis=analysis, analysis_dir=analysis_dir, ) - assert 'predictive_datasets' in restored._persisted_fit_state_sidecar - assert any( - 'Ignoring Bayesian sidecar file path outside the analysis directory' in warning - for warning in warnings - ) + import h5py + + with h5py.File(analysis_dir / 'results.h5', 'r') as handle: + assert 'posterior' not in handle + assert 'alpha' not in handle['distribution_cache'] + assert 'alpha__beta' not in handle['pair_cache'] + assert 'hrpt' in handle['predictive'] + + +def test_should_use_sidecar_compares_to_fit_result_kind_enum(): + """`_should_use_sidecar` must read from `FitResultKindEnum`, not a literal.""" + from easydiffraction.analysis.enums import FitResultKindEnum + from easydiffraction.io.results_sidecar import _should_use_sidecar + + deterministic_analysis = _analysis_with_sidecar_payload() + deterministic_analysis.fit_result._set_result_kind(FitResultKindEnum.DETERMINISTIC.value) + + bayesian_analysis = _analysis_with_sidecar_payload() + bayesian_analysis.fit_result._set_result_kind(FitResultKindEnum.BAYESIAN.value) + + assert _should_use_sidecar(deterministic_analysis) is False + assert _should_use_sidecar(bayesian_analysis) is True diff --git a/tests/unit/easydiffraction/project/categories/chart/test_default.py b/tests/unit/easydiffraction/project/categories/chart/test_default.py new file mode 100644 index 000000000..6cf297dec --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/chart/test_default.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gemmi + + +def test_chart_defaults(): + from easydiffraction.display.plotting import PlotterEngineEnum + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + + assert chart.type_info.tag == 'default' + assert chart._identity.category_code == 'chart' + assert chart.type == 'auto' + assert chart.plotter.engine in [member.value for member in PlotterEngineEnum] + + +def test_chart_plotter_binds_parent(): + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + parent = object() + chart._parent = parent + + plotter = chart.plotter + + assert plotter._project is parent + + +def test_chart_selector_updates_engine(): + from easydiffraction.display.plotting import PlotterEngineEnum + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + + chart._set_type('plotly') + + assert chart.type == 'plotly' + assert chart.plotter.engine == 'plotly' + + chart._set_type('auto') + + assert chart.type == 'auto' + assert chart.plotter.engine == PlotterEngineEnum.default().value + + +def test_chart_from_cif_restores_type(): + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + + swapped: list[tuple[str, dict]] = [] + + class _Parent: + def _swap_chart(self, new_type, *, strict): + swapped.append((new_type, {'strict': strict})) + chart._set_type(new_type, strict=strict) + + chart._parent = _Parent() + block = gemmi.cif.read_string( + 'data_test\n_chart.type plotly\n', + ).sole_block() + + chart.from_cif(block) + + assert swapped == [('plotly', {'strict': False})] + assert chart.type == 'plotly' + + +def test_chart_invalid_type_assignment_raises(): + import pytest + + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + initial_type = chart.type + + with pytest.raises(ValueError, match='Unsupported chart type'): + chart._set_type('bogus-engine') + + assert chart.type == initial_type + + +def test_chart_from_cif_tolerates_invalid_type(monkeypatch): + from easydiffraction.project.categories.chart import default as chart_mod + from easydiffraction.project.categories.chart.default import Chart + + chart = Chart() + chart._parent = type( + 'P', + (), + {'_swap_chart': lambda self, t, *, strict: chart._set_type(t, strict=strict)}, + )() + block = gemmi.cif.read_string( + 'data_test\n_chart.type bogus-engine\n', + ).sole_block() + + warnings: list[str] = [] + monkeypatch.setattr(chart_mod.log, 'warning', warnings.append) + chart.from_cif(block) + + assert chart.type == 'auto' + assert any('Unsupported chart type' in w for w in warnings) diff --git a/tests/unit/easydiffraction/project/categories/chart/test_factory.py b/tests/unit/easydiffraction/project/categories/chart/test_factory.py new file mode 100644 index 000000000..e659835fa --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/chart/test_factory.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_chart_factory_default_and_create(): + from easydiffraction.project.categories.chart.default import Chart + from easydiffraction.project.categories.chart.factory import ChartFactory + + assert ChartFactory.default_tag() == 'default' + assert 'default' in ChartFactory.supported_tags() + + chart = ChartFactory.create('default') + + assert isinstance(chart, Chart) + + +def test_chart_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.chart.factory import ChartFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + ChartFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/rendering/test_default.py b/tests/unit/easydiffraction/project/categories/rendering/test_default.py deleted file mode 100644 index 07a168ae2..000000000 --- a/tests/unit/easydiffraction/project/categories/rendering/test_default.py +++ /dev/null @@ -1,69 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import gemmi - - -def test_rendering_defaults(): - from easydiffraction.display.plotting import PlotterEngineEnum - from easydiffraction.display.tables import TableEngineEnum - from easydiffraction.project.categories.rendering.default import Rendering - - rendering = Rendering() - - assert rendering.type_info.tag == 'default' - assert rendering._identity.category_code == 'rendering' - assert rendering.chart_engine.value == 'auto' - assert rendering.table_engine.value == 'auto' - assert rendering.plotter.engine in [member.value for member in PlotterEngineEnum] - assert rendering.tabler.engine in [member.value for member in TableEngineEnum] - - -def test_rendering_plotter_binds_parent(): - from easydiffraction.project.categories.rendering.default import Rendering - - rendering = Rendering() - parent = object() - rendering._parent = parent - - plotter = rendering.plotter - - assert plotter._project is parent - - -def test_rendering_setters_update_engines(): - from easydiffraction.display.plotting import PlotterEngineEnum - from easydiffraction.display.tables import TableEngineEnum - from easydiffraction.project.categories.rendering.default import Rendering - - rendering = Rendering() - - rendering.chart_engine = 'plotly' - rendering.table_engine = 'rich' - - assert rendering.chart_engine.value == 'plotly' - assert rendering.plotter.engine == 'plotly' - assert rendering.table_engine.value == 'rich' - assert rendering.tabler.engine == 'rich' - - rendering.chart_engine = 'auto' - rendering.table_engine = 'auto' - - assert rendering.chart_engine.value == 'auto' - assert rendering.table_engine.value == 'auto' - assert rendering.plotter.engine == PlotterEngineEnum.default().value - assert rendering.tabler.engine == TableEngineEnum.default().value - - -def test_rendering_from_cif_restores_types(): - from easydiffraction.project.categories.rendering.default import Rendering - - rendering = Rendering() - block = gemmi.cif.read_string( - 'data_test\n_rendering.chart_engine plotly\n_rendering.table_engine rich\n', - ).sole_block() - - rendering.from_cif(block) - - assert rendering.chart_engine.value == 'plotly' - assert rendering.table_engine.value == 'rich' diff --git a/tests/unit/easydiffraction/project/categories/rendering/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering/test_factory.py deleted file mode 100644 index 0f2812e5c..000000000 --- a/tests/unit/easydiffraction/project/categories/rendering/test_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import pytest - - -def test_rendering_factory_default_and_create(): - from easydiffraction.project.categories.rendering.default import Rendering - from easydiffraction.project.categories.rendering.factory import RenderingFactory - - assert RenderingFactory.default_tag() == 'default' - assert 'default' in RenderingFactory.supported_tags() - - rendering = RenderingFactory.create('default') - - assert isinstance(rendering, Rendering) - - -def test_rendering_factory_rejects_unknown_tag(): - from easydiffraction.project.categories.rendering.factory import RenderingFactory - - with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): - RenderingFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/table/test_default.py b/tests/unit/easydiffraction/project/categories/table/test_default.py new file mode 100644 index 000000000..50dbccf8e --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/table/test_default.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gemmi + + +def test_table_defaults(): + from easydiffraction.display.tables import TableEngineEnum + from easydiffraction.project.categories.table.default import Table + + table = Table() + + assert table.type_info.tag == 'default' + assert table._identity.category_code == 'table' + assert table.type == 'auto' + assert table.tabler.engine in [member.value for member in TableEngineEnum] + + +def test_table_selector_updates_engine(): + from easydiffraction.display.tables import TableEngineEnum + from easydiffraction.project.categories.table.default import Table + + table = Table() + + table._set_type('rich') + + assert table.type == 'rich' + assert table.tabler.engine == 'rich' + + table._set_type('auto') + + assert table.type == 'auto' + assert table.tabler.engine == TableEngineEnum.default().value + + +def test_table_from_cif_restores_type(): + from easydiffraction.project.categories.table.default import Table + + table = Table() + + swapped: list[tuple[str, dict]] = [] + + class _Parent: + def _swap_table(self, new_type, *, strict): + swapped.append((new_type, {'strict': strict})) + table._set_type(new_type, strict=strict) + + table._parent = _Parent() + block = gemmi.cif.read_string( + 'data_test\n_table.type rich\n', + ).sole_block() + + table.from_cif(block) + + assert swapped == [('rich', {'strict': False})] + assert table.type == 'rich' + + +def test_table_invalid_type_assignment_raises(): + import pytest + + from easydiffraction.project.categories.table.default import Table + + table = Table() + initial_type = table.type + + with pytest.raises(ValueError, match='Unsupported table type'): + table._set_type('bogus-engine') + + assert table.type == initial_type diff --git a/tests/unit/easydiffraction/project/categories/table/test_factory.py b/tests/unit/easydiffraction/project/categories/table/test_factory.py new file mode 100644 index 000000000..72c9baa95 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/table/test_factory.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_table_factory_default_and_create(): + from easydiffraction.project.categories.table.default import Table + from easydiffraction.project.categories.table.factory import TableFactory + + assert TableFactory.default_tag() == 'default' + assert 'default' in TableFactory.supported_tags() + + table = TableFactory.create('default') + + assert isinstance(table, Table) + + +def test_table_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.table.factory import TableFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + TableFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index f534da493..23de62edb 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -62,7 +62,7 @@ def _recorder(*args, **kwargs): bayesian_predictive_datasets=[], _persisted_fit_state_sidecar={}, ), - rendering=SimpleNamespace(plotter=plotter), + chart=SimpleNamespace(plotter=plotter), experiments={'hrpt': SimpleNamespace(type=SimpleNamespace())}, free_parameters=[], verbosity=SimpleNamespace(fit=SimpleNamespace(value='full')), @@ -167,7 +167,6 @@ def test_project_display_help_lists_namespaces_and_methods(capsys): display.help() out = capsys.readouterr().out - assert "Help for 'ProjectDisplay'" in out assert 'parameters' in out assert 'fit' in out assert 'posterior' in out @@ -184,18 +183,15 @@ def test_nested_project_display_help_lists_methods(capsys): display.posterior.help() out = capsys.readouterr().out - assert "Help for 'ParameterDisplay'" in out assert 'all()' in out assert 'access()' in out - assert "Help for 'FitDisplay'" in out assert 'results()' in out assert 'correlations()' in out - assert "Help for 'PosteriorDisplay'" in out assert 'pairs()' in out assert 'predictive()' in out -def test_fit_display_delegates_to_analysis_and_rendering(): +def test_fit_display_delegates_to_analysis_and_chart(): project, calls = _make_project_stub() display = ProjectDisplay(project) @@ -232,7 +228,7 @@ def test_fit_display_delegates_to_analysis_and_rendering(): ) -def test_posterior_display_delegates_to_rendering_plotter(monkeypatch): +def test_posterior_display_delegates_to_chart_plotter(monkeypatch): import easydiffraction.project.display as display_mod project, calls = _make_project_stub() @@ -304,8 +300,8 @@ def test_posterior_predictive_skips_processing_indicator_for_restored_cache(monk }, ) project.experiments = {'hrpt': SimpleNamespace(type=SimpleNamespace())} - project.rendering.plotter.engine = 'plotly' - project.rendering.plotter._resolve_x_axis = lambda expt_type, x: ( + project.chart.plotter.engine = 'plotly' + project.chart.plotter._resolve_x_axis = lambda expt_type, x: ( 'two_theta', 'two_theta', None, @@ -344,7 +340,7 @@ def fake_activity_indicator(label, *, verbosity): def test_posterior_distribution_without_param_plots_all_free_parameters(): project, calls = _make_project_stub() project.free_parameters = ['a', 'b'] - project.rendering.plotter.engine = 'plotly' + project.chart.plotter.engine = 'plotly' display = ProjectDisplay(project) display.posterior.distribution() @@ -358,7 +354,7 @@ def test_posterior_distribution_without_param_plots_all_free_parameters(): def test_posterior_distribution_without_param_plots_all_free_parameters_for_ascii(): project, calls = _make_project_stub() project.free_parameters = ['a', 'b'] - project.rendering.plotter.engine = 'asciichartpy' + project.chart.plotter.engine = 'asciichartpy' display = ProjectDisplay(project) display.posterior.distribution() @@ -549,9 +545,9 @@ def test_pattern_option_statuses_ignore_placeholder_arrays_without_usable_state( experiments={'hrpt': experiment}, structures=SimpleNamespace(names=['phase-a']), analysis=SimpleNamespace(fit_results=None), - rendering=SimpleNamespace( + chart=SimpleNamespace( plotter=SimpleNamespace(_update_project_categories=lambda expt_name: None), - chart_engine=SimpleNamespace(value='plotly'), + type='plotly', ), ) display = ProjectDisplay(project) @@ -595,12 +591,12 @@ def _recorder(*args, **kwargs): experiments={'heidi': experiment}, structures=SimpleNamespace(names=['si']), analysis=SimpleNamespace(fit_results=None), - rendering=SimpleNamespace( + chart=SimpleNamespace( plotter=SimpleNamespace( _update_project_categories=lambda expt_name: None, _plot_meas_vs_calc_request=record('_plot_meas_vs_calc_request'), ), - chart_engine=SimpleNamespace(value='plotly'), + type='plotly', ), ) display = ProjectDisplay(project) diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py index 3afbd363e..0236b7689 100644 --- a/tests/unit/easydiffraction/project/test_project.py +++ b/tests/unit/easydiffraction/project/test_project.py @@ -20,7 +20,6 @@ def test_project_help(capsys): p = Project() p.help() out = capsys.readouterr().out - assert "Help for 'Project'" in out assert 'experiments' in out assert 'analysis' in out assert 'summary' in out @@ -67,14 +66,16 @@ def test_project_free_params_aggregate_structures_and_experiments(): assert project.free_parameters == [structure_param, experiment_param] -def test_project_exposes_rendering_and_display_facades(): - from easydiffraction.project.categories.rendering import Rendering +def test_project_exposes_chart_table_and_display_facades(): + from easydiffraction.project.categories.chart import Chart + from easydiffraction.project.categories.table import Table from easydiffraction.project.display import ProjectDisplay from easydiffraction.project.project import Project project = Project() - assert isinstance(project.rendering, Rendering) + assert isinstance(project.chart, Chart) + assert isinstance(project.table, Table) assert isinstance(project.display, ProjectDisplay) diff --git a/tests/unit/easydiffraction/project/test_project_config.py b/tests/unit/easydiffraction/project/test_project_config.py index be6576e94..f35d814a8 100644 --- a/tests/unit/easydiffraction/project/test_project_config.py +++ b/tests/unit/easydiffraction/project/test_project_config.py @@ -6,8 +6,10 @@ import datetime -def test_project_config_exposes_project_info_and_rendering_categories(): +def test_project_config_exposes_project_info_chart_and_table_categories(): from easydiffraction.core.category_owner import CategoryOwner + from easydiffraction.project.categories.chart import Chart + from easydiffraction.project.categories.table import Table from easydiffraction.project.project_config import ProjectConfig from easydiffraction.project.project_info import ProjectInfo @@ -15,8 +17,11 @@ def test_project_config_exposes_project_info_and_rendering_categories(): assert isinstance(config, CategoryOwner) assert isinstance(config.info, ProjectInfo) + assert isinstance(config.chart, Chart) + assert isinstance(config.table, Table) assert config.info._parent is config - assert config.rendering._parent is config + assert config.chart._parent is config + assert config.table._parent is config assert config.info.name == 'beer' assert config.info.title == 'Beer title' assert config.info.description == 'Some description' @@ -25,13 +30,16 @@ def test_project_config_exposes_project_info_and_rendering_categories(): assert isinstance(config.info.last_modified, datetime.datetime) assert config.verbosity._parent is config assert config.verbosity.fit.value == 'full' - assert config.categories == [config.info, config.rendering, config.verbosity] + assert config.categories == [config.info, config.chart, config.table, config.verbosity] assert config.parameters == ( - config.info.parameters + config.rendering.parameters + config.verbosity.parameters + config.info.parameters + + config.chart.parameters + + config.table.parameters + + config.verbosity.parameters ) -def test_project_config_as_cif_has_project_and_rendering_sections_without_data_header(): +def test_project_config_as_cif_has_project_chart_and_table_sections_without_data_header(): from easydiffraction.project.project_config import ProjectConfig config = ProjectConfig(name='beer', title='Beer title', description='Some description') @@ -44,14 +52,14 @@ def test_project_config_as_cif_has_project_and_rendering_sections_without_data_h assert '_project.description' in cif_text assert '_project.created' in cif_text assert '_project.last_modified' in cif_text - assert '_rendering.chart_engine' in cif_text - assert '_rendering.table_engine' in cif_text - assert '_rendering.chart_engine auto' in cif_text - assert '_rendering.table_engine auto' in cif_text + assert '_chart.type' in cif_text + assert '_table.type' in cif_text + assert '_chart.type auto' in cif_text + assert '_table.type auto' in cif_text assert '_verbosity.fit full' in cif_text -def test_project_save_and_load_use_auto_rendering_defaults_when_unset(tmp_path): +def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path): from easydiffraction.project.project import Project project = Project(name='beer', title='Beer title', description='Some description') @@ -60,14 +68,14 @@ def test_project_save_and_load_use_auto_rendering_defaults_when_unset(tmp_path): project_cif = (tmp_path / 'proj' / 'project.cif').read_text() assert not project_cif.startswith('data_') - assert '_rendering.chart_engine auto' in project_cif - assert '_rendering.table_engine auto' in project_cif + assert '_chart.type auto' in project_cif + assert '_table.type auto' in project_cif assert '_verbosity.fit full' in project_cif loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.rendering.chart_engine.value == 'auto' - assert loaded.rendering.table_engine.value == 'auto' + assert loaded.chart.type == 'auto' + assert loaded.table.type == 'auto' assert loaded.verbosity.fit.value == 'full' @@ -75,15 +83,15 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path): from easydiffraction.project.project import Project project = Project(name='beer', title='Beer title', description='Some description') - project.rendering.chart_engine = 'asciichartpy' - project.rendering.table_engine = 'rich' + project.chart.type = 'asciichartpy' + project.table.type = 'rich' project.save_as(str(tmp_path / 'proj')) project_cif = (tmp_path / 'proj' / 'project.cif').read_text() assert not project_cif.startswith('data_') assert '_project.id beer' in project_cif - assert '_rendering.chart_engine asciichartpy' in project_cif - assert '_rendering.table_engine rich' in project_cif + assert '_chart.type asciichartpy' in project_cif + assert '_table.type rich' in project_cif assert '_verbosity.fit full' in project_cif loaded = Project.load(str(tmp_path / 'proj')) @@ -92,8 +100,8 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path): assert loaded.info.description == 'Some description' assert isinstance(loaded.info.created, datetime.datetime) assert isinstance(loaded.info.last_modified, datetime.datetime) - assert loaded.rendering.chart_engine.value == 'asciichartpy' - assert loaded.rendering.table_engine.value == 'rich' + assert loaded.chart.type == 'asciichartpy' + assert loaded.table.type == 'rich' assert loaded.verbosity.fit.value == 'full' diff --git a/tests/unit/easydiffraction/project/test_project_load.py b/tests/unit/easydiffraction/project/test_project_load.py index f19b4392d..c726172f4 100644 --- a/tests/unit/easydiffraction/project/test_project_load.py +++ b/tests/unit/easydiffraction/project/test_project_load.py @@ -70,27 +70,27 @@ def test_round_trips_minimizer(self, tmp_path): loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.analysis.fitting.minimizer_type.value == 'lmfit (leastsq)' + assert loaded.analysis.minimizer.type == 'lmfit (leastsq)' def test_round_trips_fit_mode(self, tmp_path): original = Project(name='a2') - original.analysis.fitting_mode_type = 'joint' + original.analysis.fitting_mode.type = 'joint' original.save_as(str(tmp_path / 'proj')) loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.analysis.fitting_mode_type == 'joint' + assert loaded.analysis.fitting_mode.type == 'joint' - def test_round_trips_rendering_configuration(self, tmp_path): + def test_round_trips_display_engine_configuration(self, tmp_path): original = Project(name='d1') - original.rendering.chart_engine = 'asciichartpy' - original.rendering.table_engine = 'rich' + original.chart.type = 'asciichartpy' + original.table.type = 'rich' original.save_as(str(tmp_path / 'proj')) loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.rendering.chart_engine.value == 'asciichartpy' - assert loaded.rendering.table_engine.value == 'rich' + assert loaded.chart.type == 'asciichartpy' + assert loaded.table.type == 'rich' def test_round_trips_constraints(self, tmp_path): original = Project(name='c1') @@ -154,8 +154,6 @@ def test_round_trips_deterministic_fit_state_and_keeps_live_parameter_values(sel original.analysis.fit_result._set_iterations(37) original.analysis.fit_result._set_fitting_time(1.82) original.analysis.fit_result._set_reduced_chi_square(1.031) - original.analysis.deterministic_result._set_optimizer_name('lmfit') - original.analysis.deterministic_result._set_method_name('leastsq') original.analysis._set_has_persisted_fit_state(value=True) original.save_as(str(tmp_path / 'proj')) @@ -213,16 +211,14 @@ def test_round_trips_persisted_deterministic_correlation_summary_for_reloaded_di original.analysis.fit_result._set_iterations(21) original.analysis.fit_result._set_fitting_time(0.74) original.analysis.fit_result._set_reduced_chi_square(1.031) - original.analysis.deterministic_result._set_optimizer_name('lmfit') - original.analysis.deterministic_result._set_method_name('leastsq') - original.analysis.deterministic_result._set_objective_name('chi-square') - original.analysis.deterministic_result._set_objective_value(1.031) - original.analysis.deterministic_result._set_n_data_points(120) - original.analysis.deterministic_result._set_n_parameters(2) - original.analysis.deterministic_result._set_n_free_parameters(2) - original.analysis.deterministic_result._set_degrees_of_freedom(118) - original.analysis.deterministic_result._set_covariance_available(value=False) - original.analysis.deterministic_result._set_correlation_available(value=True) + original.analysis.minimizer._set_objective_name('chi-square') + original.analysis.minimizer._set_objective_value(1.031) + original.analysis.minimizer._set_n_data_points(120) + original.analysis.minimizer._set_n_parameters(2) + original.analysis.minimizer._set_n_free_parameters(2) + original.analysis.minimizer._set_degrees_of_freedom(118) + original.analysis.minimizer._set_covariance_available(value=False) + original.analysis.minimizer._set_correlation_available(value=True) original.analysis.fit_parameter_correlations.create( source_kind='deterministic', param_unique_name_i=parameter_b.unique_name, @@ -246,47 +242,50 @@ def test_round_trips_persisted_deterministic_correlation_summary_for_reloaded_di assert corr_df.loc[parameter_a.unique_name, parameter_b.unique_name] == pytest.approx(0.42) assert corr_df.loc[parameter_b.unique_name, parameter_a.unique_name] == pytest.approx(0.42) - def test_round_trips_bayesian_sampler_settings_to_live_dream_minimizer(self, tmp_path): + def test_round_trips_dream_minimizer_settings(self, tmp_path): original = Project(name='bayes_state') - original.analysis.fitting.minimizer_type = 'bumps (dream)' + original.analysis.minimizer.type = 'bumps (dream)' original.analysis.fit_result._set_result_kind('bayesian') - original.analysis.bayesian_sampler._set_steps(300) - original.analysis.bayesian_sampler._set_burn(60) - original.analysis.bayesian_sampler._set_thin(2) - original.analysis.bayesian_sampler._set_pop(8) - original.analysis.bayesian_sampler._set_parallel(0) - original.analysis.bayesian_sampler._set_init('lhs') + minimizer = original.analysis.minimizer + minimizer.sampling_steps = 300 + minimizer.burn_in_steps = 60 + minimizer.thinning_interval = 2 + minimizer.population_size = 8 + minimizer.parallel_workers = 0 + minimizer.initialization_method = 'latin_hypercube' original.analysis._set_has_persisted_fit_state(value=True) original.save_as(str(tmp_path / 'proj')) loaded = Project.load(str(tmp_path / 'proj')) - minimizer = loaded.analysis.fitting.minimizer + minimizer = loaded.analysis.minimizer assert minimizer is not None - assert minimizer.steps == 300 - assert minimizer.burn == 60 - assert minimizer.thin == 2 - assert minimizer.pop == 8 - assert minimizer.parallel == 0 - assert minimizer.init.value == 'lhs' - - def test_round_trips_legacy_bayesian_steps_and_burn_to_live_dream_minimizer(self, tmp_path): - original = Project(name='legacy_bayes_state') - original.analysis.fitting.minimizer_type = 'bumps (dream)' + assert minimizer.sampling_steps.value == 300 + assert minimizer.burn_in_steps.value == 60 + assert minimizer.thinning_interval.value == 2 + assert minimizer.population_size.value == 8 + assert minimizer.parallel_workers.value == 0 + assert minimizer.initialization_method.value == 'latin_hypercube' + assert minimizer._native_kwargs()['init'] == 'lhs' + + def test_round_trips_partial_dream_minimizer_settings(self, tmp_path): + original = Project(name='partial_bayes_state') + original.analysis.minimizer.type = 'bumps (dream)' original.analysis.fit_result._set_result_kind('bayesian') - original.analysis.bayesian_sampler._set_steps(300) - original.analysis.bayesian_sampler._set_burn(60) + minimizer = original.analysis.minimizer + minimizer.sampling_steps = 300 + minimizer.burn_in_steps = 60 original.analysis._set_has_persisted_fit_state(value=True) original.save_as(str(tmp_path / 'proj')) loaded = Project.load(str(tmp_path / 'proj')) - minimizer = loaded.analysis.fitting.minimizer + minimizer = loaded.analysis.minimizer assert minimizer is not None - assert minimizer.steps == 300 - assert minimizer.burn == 60 - assert minimizer.thin == 1 - assert minimizer.pop == 4 + assert minimizer.sampling_steps.value == 300 + assert minimizer.burn_in_steps.value == 60 + assert minimizer.thinning_interval.value == 1 + assert minimizer.population_size.value == 4 class TestLoadAnalysisCifFallback: @@ -301,7 +300,7 @@ def test_loads_analysis_from_subdir(self, tmp_path): assert (tmp_path / 'proj' / 'analysis' / 'analysis.cif').is_file() loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.analysis.fitting.minimizer_type.value == 'lmfit (leastsq)' + assert loaded.analysis.minimizer.type == 'lmfit (leastsq)' def test_loads_analysis_from_root_fallback(self, tmp_path): """Old layout fallback: analysis.cif at project root.""" @@ -315,4 +314,4 @@ def test_loads_analysis_from_root_fallback(self, tmp_path): analysis_dir.rmdir() loaded = Project.load(str(proj_dir)) - assert loaded.analysis.fitting.minimizer_type.value == 'lmfit (leastsq)' + assert loaded.analysis.minimizer.type == 'lmfit (leastsq)' diff --git a/tests/unit/easydiffraction/summary/test_summary.py b/tests/unit/easydiffraction/summary/test_summary.py index 0619aaf1c..3d5cc84ea 100644 --- a/tests/unit/easydiffraction/summary/test_summary.py +++ b/tests/unit/easydiffraction/summary/test_summary.py @@ -28,10 +28,10 @@ def __init__(self): self.experiments = {} # empty mapping to exercise loops safely class A: - class Fitting: - minimizer_type = type('V', (), {'value': 'lmfit'})() + class Minimizer: + type = 'lmfit' - fitting = Fitting() + minimizer = Minimizer() class R: reduced_chi_square = 0.0 @@ -59,7 +59,6 @@ class P: s = Summary(P()) s.help() out = capsys.readouterr().out - assert "Help for 'Summary'" in out assert 'show_report()' in out assert 'show_project_info()' in out assert 'show_fitting_details()' in out diff --git a/tests/unit/easydiffraction/summary/test_summary_details.py b/tests/unit/easydiffraction/summary/test_summary_details.py index 69158b7d6..b03ac92ef 100644 --- a/tests/unit/easydiffraction/summary/test_summary_details.py +++ b/tests/unit/easydiffraction/summary/test_summary_details.py @@ -57,6 +57,7 @@ def _public_attrs(self): class _Peak: def __init__(self): + self.type = 'pseudo-Voigt' self.broad_gauss_u = _Val(0.1) self.broad_gauss_v = _Val(0.2) self.broad_gauss_w = _Val(0.3) @@ -87,17 +88,16 @@ def __init__(self): }, ) self.type = typ() - self.calculation = type( - 'Calculation', + self.calculator = type( + 'Calculator', (), - {'calculator_type': _Val('cryspy')}, + {'type': 'cryspy'}, )() self.instrument = _Instr() - self.peak_profile_type = 'pseudo-Voigt' self.peak = _Peak() def _public_attrs(self): - return ['instrument', 'peak_profile_type', 'peak'] + return ['instrument', 'peak'] class _Info: @@ -112,10 +112,10 @@ def __init__(self): self.experiments = {'exp1': _Expt()} class A: - class Fitting: - minimizer_type = _Val('lmfit') + class Minimizer: + type = 'lmfit' - fitting = Fitting() + minimizer = Minimizer() class R: reduced_chi_square = 1.23 diff --git a/tests/unit/easydiffraction/test___main__.py b/tests/unit/easydiffraction/test___main__.py index a78710b35..52623534c 100644 --- a/tests/unit/easydiffraction/test___main__.py +++ b/tests/unit/easydiffraction/test___main__.py @@ -163,7 +163,10 @@ class FakeProject: experiments = [FakeExperiment()] class _analysis: - fitting_mode_type = 'sequential' + class _fitting_mode: + type = 'sequential' + + fitting_mode = _fitting_mode() @staticmethod def fit(): diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py index 8c3d2e760..3fb165574 100644 --- a/tests/unit/easydiffraction/utils/test_utils.py +++ b/tests/unit/easydiffraction/utils/test_utils.py @@ -159,7 +159,6 @@ def _hidden(self): MUT.render_object_help(Example()) out = capsys.readouterr().out - assert "Help for 'Example'" in out assert 'Properties' in out assert 'value' in out assert 'Visible value.' in out From 67c842e11eb0784e8700fa4d89ef4f87d25c5d14 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Sun, 24 May 2026 18:22:23 +0200 Subject: [PATCH 02/12] Split minimizer settings from fit-result outputs (#181) * Propose minimizer input/output split ADR * Address review 1 findings on minimizer input/output split * Address review 2: demote credible intervals; clarify context table * Address review 3: fix context list for credible-interval fields * Reply to review 4: all findings already addressed in earlier replies * Reply to review 5: both findings already addressed * Reply to review 6: single finding already addressed in reply 3 * Reply to review 7: clean signoff, no findings * Draft minimizer input/output split implementation plan * Address plan review 1: reset paths, defaults, imports, factory * Address plan review 2: migration map, reset hooks, CIF ordering * Address plan review 3: lead P1.11 with no-reordering rule * Rename FitResult to FitResultBase, add reset hooks * Add LeastSquaresFitResult class * Add BayesianFitResult class * Register fit-result family classes with factory * Declare paired _fit_result_class on minimizer bases * Wire fit_result swap and reset paths to paired class * Route LSQ result writers to fit_result * Route Bayesian result writers to fit_result * Remove LSQ output descriptors from minimizer base * Remove Bayesian output descriptors from minimizer base * Serialize fit outputs to _fit_result.* tags * Confirm fit_result paired instance flows through serializer * Add settings-used block to fit.results display * Amend affected ADRs for minimizer input/output split * Update tutorials to read outputs from fit_result * Remove essdiffraction dependency * Promote minimizer-input-output-split ADR * Complete Phase 1 minimizer split review gate * Propose IUCr CIF tag alignment for fit outputs * Add review 4 of input/output split post-Phase 1 * Reply to minimizer input-output review 4 * Complete minimizer input-output Phase 2 * Reply to minimizer input-output review 5 * Reply to minimizer input-output review 6 * Reply to minimizer input-output review 7 * Drop minimizer-input-output-split review/reply files --- .../adrs/accepted/analysis-cif-fit-state.md | 31 +- docs/dev/adrs/accepted/display-ux.md | 6 + .../minimizer-category-consolidation.md | 41 +- .../accepted/minimizer-input-output-split.md | 407 ++++++ docs/dev/adrs/accepted/runtime-fit-results.md | 8 +- .../switchable-category-owned-selectors.md | 8 + docs/dev/adrs/index.md | 1 + .../suggestions/iucr-cif-tag-alignment.md | 184 +++ docs/dev/issues/open.md | 45 + docs/dev/package-structure/full.md | 11 +- docs/dev/package-structure/short.md | 5 +- .../dev/plans/minimizer-input-output-split.md | 674 ++++++++++ docs/docs/tutorials/ed-24.ipynb | 4 +- pixi.lock | 1124 ----------------- pyproject.toml | 1 - src/easydiffraction/analysis/__init__.py | 2 +- src/easydiffraction/analysis/analysis.py | 181 +-- .../analysis/calculators/crysfml.py | 40 +- .../analysis/calculators/pdffit.py | 14 +- .../analysis/categories/__init__.py | 2 +- .../categories/fit_result/__init__.py | 4 +- .../analysis/categories/fit_result/base.py | 153 +++ .../categories/fit_result/bayesian.py | 207 +++ .../analysis/categories/fit_result/default.py | 132 +- .../analysis/categories/fit_result/lsq.py | 220 ++++ .../analysis/categories/minimizer/base.py | 2 + .../categories/minimizer/bayesian_base.py | 202 +-- .../categories/minimizer/bumps_dream.py | 11 +- .../analysis/categories/minimizer/lsq_base.py | 237 +--- src/easydiffraction/analysis/sequential.py | 121 +- src/easydiffraction/core/singleton.py | 45 +- src/easydiffraction/display/plotting.py | 73 +- src/easydiffraction/io/cif/serialize.py | 33 +- src/easydiffraction/project/display.py | 35 +- .../dream/test_package_import.py | 17 +- .../categories/fit_result/test_base.py | 50 + .../categories/fit_result/test_bayesian.py | 52 + .../categories/fit_result/test_factory.py | 37 + .../categories/fit_result/test_lsq.py | 55 + .../categories/minimizer/test_lsq_base.py | 25 +- .../analysis/categories/test_fit_result.py | 4 +- .../analysis/categories/test_fit_state.py | 25 +- .../io/test_results_sidecar.py | 6 +- .../easydiffraction/project/test_display.py | 1 + .../project/test_project_load.py | 16 +- 45 files changed, 2542 insertions(+), 2010 deletions(-) create mode 100644 docs/dev/adrs/accepted/minimizer-input-output-split.md create mode 100644 docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md create mode 100644 docs/dev/plans/minimizer-input-output-split.md create mode 100644 src/easydiffraction/analysis/categories/fit_result/base.py create mode 100644 src/easydiffraction/analysis/categories/fit_result/bayesian.py create mode 100644 src/easydiffraction/analysis/categories/fit_result/lsq.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py create mode 100644 tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py diff --git a/docs/dev/adrs/accepted/analysis-cif-fit-state.md b/docs/dev/adrs/accepted/analysis-cif-fit-state.md index 9883a3b94..0cc5e658a 100644 --- a/docs/dev/adrs/accepted/analysis-cif-fit-state.md +++ b/docs/dev/adrs/accepted/analysis-cif-fit-state.md @@ -26,7 +26,7 @@ Analysis-owned fit state needs to persist: - pre-fit scalar snapshots for recovery workflows - compact status metadata for the latest saved fit projection - deterministic correlation summaries -- minimizer-specific fit outputs on the active `_minimizer.*` category +- minimizer-specific fit outputs on the paired `_fit_result.*` category - per-parameter posterior summaries on `_fit_parameter` - large posterior arrays and plot caches in `analysis/results.h5` @@ -47,8 +47,7 @@ Persist analysis-owned fit state as explicit analysis categories in Do not add a dedicated `_fit_state` category or `_fit_state.schema_version`. Persisted fit state is detected from -`_fit_result`, `_fit_parameter`, `_fit_parameter_correlation`, and -fit-output fields on `_minimizer.*`. +`_fit_result`, `_fit_parameter`, and `_fit_parameter_correlation`. ### Common fit-state categories @@ -77,7 +76,8 @@ pre-fit scalar snapshots: - `posterior_gelman_rubin` - `posterior_effective_sample_size_bulk` -`_fit_result` stores the latest saved fit header: +`_fit_result` stores the latest saved fit header and scalar +family-specific fit outputs: - `result_kind` - `success` @@ -92,9 +92,9 @@ pairs are stored. ### Minimizer fit projection -The active `_minimizer.*` category stores both user-selected solver -inputs and fit-filled outputs. Deterministic minimizer classes store -compact fit output counts: +The active `_minimizer.*` category stores user-selected solver inputs +only. Scalar outputs are written to the paired `_fit_result.*` category. +Deterministic fit-result classes add compact fit output counts: - `objective_name` - `objective_value` @@ -104,8 +104,6 @@ compact fit output counts: - `degrees_of_freedom` - `covariance_available` - `correlation_available` -- `runtime_seconds` -- `iterations_performed` - `exit_reason` Do not persist a `_deterministic_parameter_result` category. Final @@ -113,8 +111,7 @@ deterministic parameter values and uncertainties already persist in the model CIF files, and restored deterministic ordering comes from `_fit_parameter`. -Bayesian minimizer classes store sampler inputs and fit outputs under -`_minimizer.*`, including: +Bayesian minimizer classes store sampler inputs under `_minimizer.*`: - `sampling_steps` - `burn_in_steps` @@ -123,7 +120,9 @@ Bayesian minimizer classes store sampler inputs and fit outputs under - `parallel_workers` - `initialization_method` - `random_seed` -- `runtime_seconds` + +Bayesian fit-result classes store scalar outputs under `_fit_result.*`: + - `point_estimate_name` - `sampler_completed` - `credible_interval_inner` @@ -170,10 +169,10 @@ posterior displays. Load order is: 1. standard analysis configuration -2. common fit-state categories -3. `_minimizer.*` fit-output fields according to the active - `_minimizer.type` -4. posterior sidecar arrays when a Bayesian result is expected +2. `_minimizer.*` settings according to the active `_minimizer.type` +3. common and family-specific `_fit_result.*` fields on the paired class +4. `_fit_parameter` and `_fit_parameter_correlation` +5. posterior sidecar arrays when a Bayesian result is expected Persist backend runtime objects, optimizer instances, and raw driver payloads nowhere in this design. diff --git a/docs/dev/adrs/accepted/display-ux.md b/docs/dev/adrs/accepted/display-ux.md index 0b33b708e..379bdb8c8 100644 --- a/docs/dev/adrs/accepted/display-ux.md +++ b/docs/dev/adrs/accepted/display-ux.md @@ -200,6 +200,12 @@ Use these naming rules: path for `versus`. - `posterior.*` names are used only when posterior samples are required. +`project.display.fit.results()` also prints a "Settings used" block +above the result tables. The block is sourced from +`analysis.minimizer.*` so the minimizer inputs and paired +`analysis.fit_result.*` outputs are visible from the accepted display +facade without adding a new `Analysis`-level display method. + ## Rejected Alternatives Flat display facade: diff --git a/docs/dev/adrs/accepted/minimizer-category-consolidation.md b/docs/dev/adrs/accepted/minimizer-category-consolidation.md index f86b56a95..ed1e24f36 100644 --- a/docs/dev/adrs/accepted/minimizer-category-consolidation.md +++ b/docs/dev/adrs/accepted/minimizer-category-consolidation.md @@ -55,31 +55,34 @@ samplers. ## Decision -### 1. Unified `minimizer` category replaces all sampler-input and fit-result categories +### 1. Unified `minimizer` category replaces sampler-input categories Introduce a single switchable category `minimizer` on `Analysis`. Its concrete class is determined by `Analysis.minimizer_type`. The category -holds both user-writable inputs and fit-filled outputs in one place. +now holds user-writable minimizer inputs only. The later +[`minimizer-input-output-split.md`](minimizer-input-output-split.md) ADR +reverses the fit-output half of this rule: scalar fit outputs live on +the paired `fit_result` category instead of on `minimizer`. The following categories are removed: - `bayesian_sampler` — fields move into the Bayesian concrete classes of `minimizer`. - `bayesian_result`, `bayesian_convergence` — fields move into the - Bayesian concrete classes of `minimizer` (`runtime_seconds`, + Bayesian concrete classes of `fit_result` (`fitting_time`, `acceptance_rate_mean`, `gelman_rubin_max`, `effective_sample_size_min`, `best_log_posterior`, …). - `deterministic_result` — fields move into the deterministic concrete - classes of `minimizer` (`runtime_seconds`, `iterations_performed`, - `exit_reason`, …). + classes of `fit_result` (`fitting_time`, `iterations`, + `objective_value`, `exit_reason`, …). - `bayesian_parameter_posterior` — replaced by `Parameter.posterior` (see §3). - `bayesian_distribution_cache`, `bayesian_pair_cache`, `bayesian_predictive_dataset` — replaced by HDF5 sidecar (see §4). -`fit_result` and `fit_parameter` (analysis-owned bounds, success flag, -reduced chi-square, message, fit time, iterations) remain unchanged as -fit-mode-agnostic header categories. +`fit_parameter` (analysis-owned bounds) remains a fit-state category. +`fit_result` remains the common fit header category and is extended by +the input/output split ADR with family-specific scalar outputs. ### 2. Selectors move to the `Analysis` owner @@ -388,10 +391,11 @@ category's class-level `_engine_metadata` dict. ### Trade-offs -- `minimizer` is the first category that mixes writable user inputs and - writable fit-filled outputs in the same scope. This is a small new - convention but is the natural generalization of how `Parameter` - already holds both user input and refined value on the same object. +- `minimizer` no longer mixes writable user inputs and fit-filled + outputs in the same scope. That stricter boundary is recorded by + [`minimizer-input-output-split.md`](minimizer-input-output-split.md); + `Parameter` remains the refinement-in-place precedent for model values + rather than minimizer diagnostics. - The set of `_minimizer.*` tags present in CIF depends on the active `_fitting.minimizer_type`. Loading a CIF whose tags don't match the minimizer's allowed set raises (clear validation, not silent @@ -461,9 +465,10 @@ category count for each new sampler and entrenches the convention break. ### D. Strict input-only `minimizer` plus a separate `fit_result` -Keep the categories single-concept (inputs xor outputs) at the cost of -two-place lookup for related info. Rejected in favour of the -one-category-mixes-both shape (§1, §"Trade-offs") because the existing -`Parameter` model already mixes input and refined value on the same -object, and one-place discoverability is more valuable than strict -purity. +Originally rejected in favour of the one-category-mixes-both shape (§1, +§"Trade-offs"). Reversed by +[`minimizer-input-output-split.md`](minimizer-input-output-split.md) +after implementation showed the `Parameter` analogy does not hold for +minimizer settings versus fit diagnostics. The current design keeps +`minimizer` input-only and moves scalar fit outputs to the paired +`fit_result` category. diff --git a/docs/dev/adrs/accepted/minimizer-input-output-split.md b/docs/dev/adrs/accepted/minimizer-input-output-split.md new file mode 100644 index 000000000..13a33a5be --- /dev/null +++ b/docs/dev/adrs/accepted/minimizer-input-output-split.md @@ -0,0 +1,407 @@ +# ADR: Minimizer Input/Output Split + +**Status:** Accepted **Date:** 2026-05-24 + +## Status Note + +This proposal revisits and supersedes Alternative D in +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +("Strict input-only `minimizer` plus a separate `fit_result`"), which +was rejected during the consolidation work on the assumption that the +input/output mix on `analysis.minimizer` was symmetric with the +input/output mix on `Parameter`. Implementation experience shows that +analogy does not hold and the mix has produced measurable UX and +duplication problems documented below. + +## Context + +After +[`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) +landed, `analysis.minimizer` holds both writable user inputs and +fit-filled outputs in a single namespace. A user typing +`analysis.minimizer.help()` before any fit sees roughly twenty +properties, the majority of which are `None`, `0`, or empty strings, +with no signal which they may write and which the fit fills in. + +The current shape on the live category surfaces: + +- **Writable inputs:** `max_iterations` (LSQ); `sampling_steps`, + `burn_in_steps`, `thinning_interval`, `population_size`, + `parallel_workers`, `initialization_method`, `random_seed` (Bayesian). +- **Fit-filled outputs (no public setter, only `_set_*` internals):** + `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, + `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, + `correlation_available`, `runtime_seconds`, `iterations_performed`, + `exit_reason` (LSQ); `runtime_seconds`, `point_estimate_name`, + `sampler_completed`, `credible_interval_inner`, + `credible_interval_outer`, `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior` (Bayesian). + +Three current output fields straddle `analysis.minimizer` and +`analysis.fit_result`: + +| Output concept | Field on `analysis.minimizer` | Field on `analysis.fit_result` | Relationship | +| ----------------------- | ----------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Wall time | `runtime_seconds` | `fitting_time` | Real duplication — same scalar in two places. | +| Iteration count | `iterations_performed` (LSQ) | `iterations` | Real duplication — same scalar in two places. | +| Objective vs reduced χ² | `objective_value` (raw χ²) | `reduced_chi_square` (χ² / dof) | Cross-category misplacement — two related but distinct scalars where the raw value sits on `minimizer` instead of with the rest of the fit outputs. | + +So a reader who wants "how long did the fit take" must already pick +between two places. The current layout has both **input/output mixed +inside `minimizer`** and **fit-output content split across `minimizer` +and `fit_result`** (whether the two scalars per row are the same value +or not). §2 resolves each row above explicitly. + +The consolidation ADR's two-line argument for keeping inputs and outputs +together was: + +1. **Symmetry with `Parameter`.** A `Parameter` holds both its user-set + initial value and its refined value plus uncertainty. +2. **One-place discoverability** > strict purity. + +The symmetry argument does not actually transfer. `Parameter.value` and +`Parameter.uncertainty` describe the _same scalar quantity_ before and +after refinement; they share a name, semantics, and lifecycle. +`minimizer.sampling_steps` (a user request) and +`minimizer.gelman_rubin_max` (a diagnostic the sampler reports) are +about completely different things and only share a namespace because the +consolidation ADR put them there. "One-place" is also already broken: +scalar fit outputs are split across `minimizer`, `fit_result`, +`fit_parameters`, and `fit_parameter_correlations` today. + +## Decision + +### 1. Split `analysis.minimizer` into inputs and outputs + +`analysis.minimizer` keeps only **writable user settings**. The +fit-filled output fields move to `analysis.fit_result`, which gets a +class hierarchy parallel to `minimizer` so each minimizer family can +declare its own output schema. + +After this ADR: + +| Category | Role | Writable | +| ------------------------------------- | -------------------------------------------------- | --------------------------- | +| `analysis.minimizer` | user-supplied settings | yes | +| `analysis.fit_result` | scalar fit outputs | no (internal `_set_*` only) | +| `analysis.fit_parameters` | per-parameter snapshots and posterior summary rows | no | +| `analysis.fit_parameter_correlations` | upper-triangle correlation rows | no | + +**`fit_result` is not a user-facing switchable category.** It is an +internal projection paired with the active `minimizer`. It does not +expose `fit_result.type` or `fit_result.show_supported()`; the only way +the user changes the active `fit_result` class is by setting +`analysis.minimizer.type`, which the owner's `_swap_minimizer` hook uses +to instantiate both `self._minimizer` and `self._fit_result` atomically. +This is an explicit, documented exception to the global selector +contract from +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) +§1 because there is no user choice involved at the `fit_result` level — +the minimizer family fully determines the result schema. See the new +exception text added to that ADR (listed under §"ADRs amended"). + +**Family mapping is one-to-one between minimizer family and result +class.** Every minimizer registered under `MinimizerTypeEnum` maps to +exactly one `FitResult` concrete class according to its family: + +| `MinimizerTypeEnum` member | Minimizer family | Paired `FitResult` class | +| -------------------------- | ---------------- | ------------------------ | +| `LMFIT` | LSQ | `LeastSquaresFitResult` | +| `LMFIT_LEASTSQ` | LSQ | `LeastSquaresFitResult` | +| `LMFIT_LEAST_SQUARES` | LSQ | `LeastSquaresFitResult` | +| `DFOLS` | LSQ | `LeastSquaresFitResult` | +| `BUMPS` | LSQ | `LeastSquaresFitResult` | +| `BUMPS_LM` | LSQ | `LeastSquaresFitResult` | +| `BUMPS_AMOEBA` | LSQ | `LeastSquaresFitResult` | +| `BUMPS_DE` | LSQ | `LeastSquaresFitResult` | +| `BUMPS_DREAM` | Bayesian | `BayesianFitResult` | +| `EMCEE` _(when added)_ | Bayesian | `BayesianFitResult` | + +The pairing rule is encoded once on the minimizer base classes +(`LeastSquaresMinimizerBase._fit_result_class = LeastSquaresFitResult`, +`BayesianMinimizerBase._fit_result_class = BayesianFitResult`) so +`_swap_minimizer` reads the paired class off the new minimizer instance +and does not need a per-tag dispatch. + +This preserves the consolidation ADR's "no `_bayesian_*` mirror" +guarantee — there is exactly one output category, not seven — while +making the input/output boundary unambiguous. + +### 2. Field assignments + +**`analysis.minimizer` after the split** (writable settings only): + +- LSQ: `max_iterations`. +- Bayesian: `sampling_steps`, `burn_in_steps`, `thinning_interval`, + `population_size`, `parallel_workers`, `initialization_method`, + `random_seed`. + +`credible_interval_inner` and `credible_interval_outer` **stay on the +output side** in this ADR, attached to `BayesianFitResult` (see below). +They are persisted with the fixed values `0.68` and `0.95`, matching the +per-parameter interval columns (`posterior_interval_68_low/high`, +`posterior_interval_95_low/high`). Promoting the levels to user-writable +settings would let the user choose a 50% interval that then gets +persisted under a column named `posterior_interval_68_low` — a +data-integrity problem rather than a UX problem. A future suggestion ADR +can promote them to settings and generalise the column naming (e.g. +`posterior_interval_low_`) in one combined change; doing one +without the other is unsafe and out of scope here. + +**`analysis.fit_result` after the split** (outputs only). Common fields +live on `FitResultBase`; family-specific fields on the concrete classes: + +- `FitResultBase`: `success`, `message`, `iterations`, `fitting_time`, + `reduced_chi_square`, `result_kind`. +- `LeastSquaresFitResult` adds: `objective_name`, `objective_value`, + `n_data_points`, `n_parameters`, `n_free_parameters`, + `degrees_of_freedom`, `covariance_available`, `correlation_available`, + `exit_reason`. +- `BayesianFitResult` adds: `point_estimate_name`, `sampler_completed`, + `credible_interval_inner`, `credible_interval_outer`, + `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior`. + +The three overlapping pairs from §"Context" are resolved by **dropping +the `minimizer` copy** and keeping the `fit_result` copy: + +- `minimizer.runtime_seconds` removed; `fit_result.fitting_time` is the + single source. +- `minimizer.iterations_performed` removed; `fit_result.iterations` is + the single source. +- `minimizer.objective_value` removed. `LeastSquaresFitResult` keeps + **two distinct fields**: `objective_value` (raw χ² returned by the + minimizer's objective function) and `reduced_chi_square` (= χ² / + `degrees_of_freedom`). They are not duplicates; the unreduced value is + what the solver actually optimises and is useful for diagnostics on + small-dof fits, while the reduced value is what every user-facing + table and plot displays. `BayesianFitResult` does not carry + `objective_value` because the Bayesian engine optimises the log + posterior rather than χ² directly. + +### 3. CIF layout follows the Python split + +The `_minimizer.*` block becomes settings-only. A new `_fit_result.*` +block (already present today for the common header fields) absorbs every +fit output. The set of `_fit_result.*` tags depends on the active +`_minimizer.type`, matching the same shape-shifting convention that +`_minimizer.*` itself already uses per +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md). + +Example deterministic fit: + +``` +_minimizer.type 'lmfit (leastsq)' +_minimizer.max_iterations 1000 + +_fit_result.result_kind deterministic +_fit_result.success true +_fit_result.message converged +_fit_result.iterations 87 +_fit_result.fitting_time 12.34 +_fit_result.reduced_chi_square 1.42 +_fit_result.objective_name chi_square +_fit_result.objective_value 1532.4 +_fit_result.n_data_points 1024 +_fit_result.n_parameters 12 +_fit_result.n_free_parameters 8 +_fit_result.degrees_of_freedom 1016 +_fit_result.covariance_available true +_fit_result.correlation_available true +_fit_result.exit_reason converged +``` + +Example Bayesian fit: + +``` +_minimizer.type 'bumps (dream)' +_minimizer.sampling_steps 3000 +_minimizer.burn_in_steps 600 +_minimizer.thinning_interval 1 +_minimizer.population_size 4 +_minimizer.parallel_workers 0 +_minimizer.initialization_method latin_hypercube +_minimizer.random_seed ? + +_fit_result.result_kind bayesian +_fit_result.success true +_fit_result.message 'sampler converged' +_fit_result.iterations 3000 +_fit_result.fitting_time 124.7 +_fit_result.reduced_chi_square 1.18 +_fit_result.point_estimate_name best_sample +_fit_result.sampler_completed true +_fit_result.credible_interval_inner 0.68 +_fit_result.credible_interval_outer 0.95 +_fit_result.acceptance_rate_mean 0.27 +_fit_result.gelman_rubin_max 1.03 +_fit_result.effective_sample_size_min 482 +_fit_result.best_log_posterior -1234.56 +``` + +### 4. The runtime `analysis.fit_results` object is the same data shaped differently + +`analysis.fit_results` (plural, runtime) is the rich `FitResults` / +`BayesianFitResults` Python object that holds posterior samples, +predictive summaries, raw engine results, and reporting helpers. This +ADR does **not** rename it. After the split it remains the +"give-me-everything" accessor; `analysis.fit_result.*` (singular, CIF +category) holds the persisted scalar projection of the same fit. The +naming pair stays as today. + +A small UX win is added under the accepted display facade +([`display-ux.md`](display-ux.md)): the existing +`project.display.fit.results()` entry point gains a "Settings used" +table above the existing results tables, populated from +`analysis.minimizer.*`. No new `Analysis`-level display method is added; +the user-facing surface stays exactly where the display ADR put it. +Internally the helper reads `self.minimizer.*` and `self.fit_result.*` +and renders one combined view. + +### 5. Help and discoverability + +`analysis.minimizer.help()` after the split lists ~7 properties for +Bayesian and 1 for LSQ — every one writable. The "is this writable" +question disappears. + +`analysis.fit_result.help()` lists 6 common output properties plus the +family-specific ones, all clearly read-only. + +### 6. No new selector wiring + +`analysis.minimizer.type` remains the single user-facing selector. The +swap hook updates **both** `analysis.minimizer` and +`analysis.fit_result` instances atomically (via +`Analysis._swap_minimizer`, which reads the paired `_fit_result_class` +off the new minimizer base). The paired `fit_result` is not a +user-facing switchable: there is no `fit_result.type` and no +`fit_result.show_supported()`, per the documented exception added to +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) +(see §"ADRs amended"). This keeps "one minimizer concept, one +user-facing type" intact and removes the temptation to swap the result +class independently of the minimizer. + +## Consequences + +### Positive + +- **Clear writable surface.** `analysis.minimizer.help()` shows only + settings. Inputs and outputs no longer mix in one namespace. +- **Single source for every output field.** The two current real + duplications (`runtime_seconds`/`fitting_time` and + `iterations_performed`/`iterations`) collapse to one location each. + The `objective_value`/`reduced_chi_square` pair is **not** a + duplication and both stay (see §2 for the distinction). +- **Family-specific outputs have a natural home.** Currently + `minimizer.gelman_rubin_max` lives on the Bayesian minimizer class; + after the split it lives on the paired `BayesianFitResult` class. The + two categories pair symmetrically and emcee inherits the pattern for + free. +- **CIF stays compact.** No new CIF blocks beyond `_fit_result.*` which + is already present. The settings/outputs split is reflected in the CIF + tag prefix. +- **The "are we done with a fit?" check becomes simple.** + `bool(analysis.fit_result.success.value)` answers it directly without + scanning a mixed input/output namespace. + +### Trade-offs + +- **Settings and matching outputs are two-place reads.** Mitigation: + `project.display.fit.results()` presents both. The current layout + already requires multi-place reads; this just makes the rule + consistent. +- **`fit_result` becomes an internally-paired category.** The pairing + cost is small (one paired-instance assignment in the existing + `_swap_minimizer` hook) and is invisible to the user — there is no + second `fit_result.type` selector. The exception to the global + selector contract is documented explicitly in §"ADRs amended" + alongside the selector ADR. +- **Saved CIF files from the post-consolidation layout cannot load + unchanged.** Beta posture (no legacy shims) applies. Tutorial fixtures + regenerate via `pixi run script-tests`. Tutorial `ed-24` already + carries a narrow archive normaliser; the new layout would extend it + once. +- **Reopens a decision from a recently accepted ADR.** Documented + explicitly above in §"Status Note". + +### ADRs amended by this ADR + +- [`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) + — §1 ("Unified `minimizer` category replaces all sampler-input and + fit-result categories") becomes a partial rule: the unified + `minimizer` holds inputs; outputs move to the paired `fit_result`. + §"Alternatives Considered → D" updated to record the reversal and the + implementation evidence that prompted it. +- [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) — §"Minimizer + fit projection" rewritten to describe the split (`_minimizer.*` + settings-only, `_fit_result.*` outputs including family-specific + fields). +- [`runtime-fit-results.md`](runtime-fit-results.md) — closing paragraph + references this ADR alongside the existing two. +- [`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) + — §1 ("The category owns its selector") gains a paragraph carving out + one documented exception: a category that is fully determined by + another category's `type` (today only `fit_result`, derived from + `minimizer.type`) is allowed to omit `category.type` and + `category.show_supported()`. The mechanism is described in §1 of this + ADR. The user-facing selector convention is otherwise unchanged. +- [`display-ux.md`](display-ux.md) — §"Fit results display" expanded to + mention that `project.display.fit.results()` now prints a "Settings + used" block above the result tables, sourced from + `analysis.minimizer.*`. No new public entry point is added. + +## Deferred Work + +- **Renaming `analysis.fit_results` (plural runtime object).** The + plural/singular pair is mildly confusing but the rename has wide blast + radius (tests, tutorials, every BayesianFitResults reference). Track + separately if the confusion remains after the combined display lands. +- **Paired internal categories beyond `minimizer` / `fit_result`.** This + ADR introduces the paired pattern for the minimizer only. If future + categories grow the same input/output asymmetry (e.g. extinction, + peak), apply the same pattern then; do not generalise pre-emptively. +- **User-configurable credible-interval levels.** The two interval + levels currently stay at the hardcoded `0.68` / `0.95`, matching the + fixed per-parameter column names (`posterior_interval_68_low` etc.). + Promoting the levels to user settings requires generalising the column + naming at the same time to avoid the data-integrity hole where a + `0.50` level lands in a column called `posterior_interval_68_low`. + Both pieces belong in a follow-on ADR so this proposal stays focused + on the input/output split. +- **CIF compatibility helper for ID 35 archive.** The + `_normalize_id35_archive_for_tutorial` helper in `ed-24.py` already + has a roadmap to deletion; the new CIF layout extends the rename map + one more line. No new architecture decision needed. + +## Alternatives Considered + +### A. Keep current mixed-category layout, fix only the duplications + +Drop `minimizer.runtime_seconds`, `.iterations_performed`, +`.objective_value` and route every reader to `fit_result.*`. Rejected +because it leaves the input/output mix on `minimizer` intact and +therefore does not fix the `minimizer.help()` discoverability problem. + +### B. Mark fields with metadata, keep one category + +Add an `is_input: bool` marker to each descriptor and have +`minimizer.help()` group inputs vs outputs in display. Rejected because +it ships the structural problem unchanged — the CIF still mixes both +under `_minimizer.*`, the duplications with `fit_result` remain, and the +`_set_*` vs writable-setter split is still ad-hoc. + +### C. Move outputs into the runtime `fit_results` object, not a CIF category + +Persist only settings in CIF; outputs live in `analysis.fit_results` at +runtime and `analysis/results.h5` on disk. Rejected because the small +scalar outputs (success, χ², runtime, R̂) are exactly what users want to +read from CIF without unpacking HDF5, and the consolidation ADR +explicitly puts them in CIF (`_minimizer.*` today). + +### D. Rename `fit_result` to mirror minimizer (`minimizer_result`) + +Make the pairing rule explicit in the name (`` and `_result`). +Rejected because the recently-accepted +[`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) +ADR deliberately drops `_type` and other suffixes from category names; +adding `_result` walks the convention back. diff --git a/docs/dev/adrs/accepted/runtime-fit-results.md b/docs/dev/adrs/accepted/runtime-fit-results.md index 282b9841d..c0cbf7b29 100644 --- a/docs/dev/adrs/accepted/runtime-fit-results.md +++ b/docs/dev/adrs/accepted/runtime-fit-results.md @@ -30,9 +30,11 @@ raw driver payloads remain runtime-only unless a narrower ADR defines a persisted projection. The accepted [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) and [`minimizer-category-consolidation.md`](minimizer-category-consolidation.md) -ADRs define the current compact projection for fit headers, -minimizer-owned outputs, parameter posterior summaries, and the -`analysis/results.h5` sidecar. +ADRs, as amended by +[`minimizer-input-output-split.md`](minimizer-input-output-split.md), +define the current compact projection for fit headers, paired fit-result +outputs, parameter posterior summaries, and the `analysis/results.h5` +sidecar. ## Consequences diff --git a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md index 19fb491bb..f74fdf849 100644 --- a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md +++ b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md @@ -101,6 +101,14 @@ Owner-level shims are removed (no `._type`, no `show_supported__types()`, no `show_current__type()`). The owner exposes only the category itself, e.g. `analysis.minimizer`. +Exception: an internally paired category whose concrete class is fully +determined by another category's `type` may omit its own public +selector. Today this applies only to `analysis.fit_result`, whose class +is derived from `analysis.minimizer.type` by the +[`minimizer-input-output-split.md`](minimizer-input-output-split.md) +ADR. It has no `fit_result.type`, no `fit_result.show_supported()`, and +no `_fit_result.type` CIF tag. + The owner still owns the swap mechanism (it holds the slot) but the swap is _initiated_ from the category through a back-reference. diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 28859e145..fcdf7bbfa 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -21,6 +21,7 @@ folders. | Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | | Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | | Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | +| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | | Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | | Analysis and fitting | Suggestion | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](suggestions/undo-fit.md) | | Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | diff --git a/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md b/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md new file mode 100644 index 000000000..052412cbd --- /dev/null +++ b/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md @@ -0,0 +1,184 @@ +# ADR: IUCr CIF Tag Alignment for Fit Outputs + +**Status:** Proposed **Date:** 2026-05-24 + +## Status Note + +This suggestion captures research done during the +[`minimizer-input-output-split`](accepted/minimizer-input-output-split.md) +work. That ADR's `_fit_result.*` CIF prefix is functional but diverges +from the IUCr core and powder dictionaries. This proposal records the +divergence and a plausible alignment path so it is not lost; the work is +**not** in scope for the input/output-split PR. + +## Context + +After the input/output split, fit outputs persist under `_fit_result.*`. +The IUCr maintains two relevant dictionaries that already cover much of +the same ground: + +- [`COMCIFS/cif_core`](https://github.com/COMCIFS/cif_core) — core CIF + dictionary, refinement parameters under `_refine_ls.*` (51 items) and + aggregate reflection statistics under `_reflns.*`. +- [`COMCIFS/Powder_Dictionary`](https://github.com/COMCIFS/Powder_Dictionary) + (`cif_pow.dic`) — powder-specific refinement under `_pd_proc_ls.*` (9 + items). + +Cross-reference today: + +| Concept | Our `_fit_result.*` | IUCr core (`_refine_ls.*`) | IUCr powder (`_pd_proc_ls.*`) | +| ----------------------- | -------------------- | ------------------------------------------------ | ------------------------------------------------ | +| Reduced χ² / GoF | `reduced_chi_square` | `goodness_of_fit_all` (S, plus `_su`) | derived from `prof_wR_factor`/`prof_wR_expected` | +| R-factor unweighted | — | `r_factor_all`, `r_factor_gt` | `prof_R_factor` | +| R-factor weighted | — | `wr_factor_all`, `wr_factor_gt`, `wr_factor_ref` | `prof_wR_factor` | +| R-expected | — | (derived from S and counts) | `prof_wR_expected` | +| Number of data points | `n_data_points` | `number_reflns`, `number_reflns_gt` | derive from `_pd_proc.number_of_points` | +| Number of parameters | `n_parameters` | `number_parameters` | (in `_refine_ls.*`) | +| Number of restraints | — | `number_restraints` | same | +| Number of constraints | — | `number_constraints` | same | +| Shift / σ | — | `shift_over_su_max`, `shift_over_su_mean` | same | +| Profile function | — | — | `profile_function` | +| Background function | — | — | `background_function` | +| Wall time | `fitting_time` | (none) | (none) | +| Iteration count | `iterations` | (none) | (none) | +| Success flag, message | `success`, `message` | (none) | (none) | +| Bayesian R̂, ESS, accept | various | (none) | (none) | + +Two consequences: + +1. **Real gaps.** Our serialization omits R-factors, + restraint/constraint counts, shift/σ diagnostics, and powder + profile/background function names. These are fields that + crystallographers expect in a saved CIF. +2. **Naming divergence.** Where IUCr does have a tag, we use a different + prefix (`_fit_result.*` vs `_refine_ls.*` / `_pd_proc_ls.*`). Our + CIFs are valid but cannot be consumed by external tools that expect + IUCr-standard names. + +## Decision + +### 1. Use IUCr tag names where they exist + +For every `_fit_result.*` field that has a one-to-one IUCr counterpart, +emit the IUCr tag instead. The Python attribute name stays +(`analysis.fit_result.reduced_chi_square`); only the `cif_handler` +`names` tuple changes. Examples: + +- `fit_result.reduced_chi_square` → emits + `_refine_ls.goodness_of_fit_all` for single-crystal, + `_pd_proc_ls.prof_wR_factor` + `_pd_proc_ls.prof_wR_expected` for + powder (or both, with the GoF derived on the powder side). +- `fit_result.n_data_points` → `_refine_ls.number_reflns` + (single-crystal), `_pd_proc.number_of_points` (powder). +- `fit_result.n_parameters` → `_refine_ls.number_parameters`. + +The emitted prefix becomes shape-shifting based on +`experiment.type.scattering_type` and `experiment.type.sample_form` — +the same convention powder packages use today. The Python API remains +uniform. + +### 2. Add the missing IUCr fields to `fit_result` + +Promote the following from "gap" to "available" on the existing +`LeastSquaresFitResult`: + +- `r_factor_all`, `wr_factor_all` — emitted as the standard tags; + computed by the LSQ projection writer from residuals. +- `prof_r_factor`, `prof_wr_factor`, `prof_wr_expected` — + powder-specific variants, computed the same way against the profile + data. +- `number_restraints`, `number_constraints` — current count from the + analysis model. +- `shift_over_su_max`, `shift_over_su_mean` — last-iteration convergence + diagnostic. +- `profile_function`, `background_function` (powder) — string-form + descriptions of the active peak and background categories. + +### 3. Keep our own prefix for fields IUCr does not cover + +`fitting_time`, `iterations`, `success`, `message`, every Bayesian +diagnostic (`gelman_rubin_max`, `acceptance_rate_mean`, etc.), and the +`result_kind`/`point_estimate_name` markers have no IUCr home today. +They stay under a project-specific prefix. Two options for that prefix: + +- Keep `_fit_result.*` for the non-IUCr fields only. The CIF then mixes + prefixes (`_refine_ls.*` + `_fit_result.*`) which is unusual but + legal. +- Use a clearly-namespaced extension like `_easydiffraction.*` or + `_eddict_fit.*` so external tools recognise the fields as non-IUCr. + +Decide during implementation; the second option is friendlier to +external CIF readers. + +### 4. Bayesian Rietveld output has no IUCr precedent today + +There is no IUCr convention for sampler convergence (R̂, ESS, acceptance) +or posterior diagnostics. This proposal does not invent one. The +Bayesian-specific fields stay under the project prefix chosen in §3. If +a community standard emerges, a follow-on ADR can absorb it. + +## Consequences + +### Positive + +- Saved CIFs become consumable by standard IUCr tools (publCIF, + checkCIF, journal submission pipelines). +- The "what is the R-factor of this fit?" question has an answer in the + saved file — currently we only record reduced χ². +- Powder users get the conventional Rp / Rwp / Rexp triplet. +- We pick up restraint/constraint accounting that the structure side of + the project already tracks but does not currently emit. + +### Trade-offs + +- Shape-shifting CIF prefix per experiment family is more work to + implement than a single `_fit_result.*` prefix. Roughly: one + `cif_handler` per descriptor that maps to a different IUCr tag + depending on context; the Python API stays uniform. +- Bayesian fields remain non-standard. There is no way around that until + IUCr defines tags for Bayesian Rietveld. +- Existing saved projects from the post-split layout cannot load + unchanged. Beta posture applies; one more legacy-rename pass in + tutorial `ed-24` covers it. + +### ADRs amended by this ADR + +- [`analysis-cif-fit-state.md`](../accepted/analysis-cif-fit-state.md) — + replace the `_fit_result.*` projection description with the + IUCr-aligned tag set; document the per-experiment-family + shape-shifting. +- [`minimizer-input-output-split.md`](../accepted/minimizer-input-output-split.md) + — the `_fit_result.*` examples in §3 are updated to use IUCr tags for + the covered fields; the non-IUCr fields keep the project-prefix + examples. + +## Deferred Work + +- The exact prefix for non-IUCr fields (§3 option choice). +- IUCr-Bayesian alignment if a community standard appears. +- Single-crystal `r_factor_gt` / `wr_factor_gt` (greater-than-σ subsets) + need a "threshold expression" decision. The + `_reflns.threshold_expression` field already covers it on the + reflection side; the LSQ projection writer needs to know the threshold + to compute the `_gt` variants. +- Whether to also emit `_refine.special_details` for human-readable fit + notes. + +## Alternatives Considered + +### A. Keep `_fit_result.*` as-is + +Simplest. Saved CIFs stay self-contained but are not IUCr-portable. +Defensible if the project never targets external CIF interop. + +### B. Emit both prefixes for the covered fields + +`_fit_result.reduced_chi_square` and `_refine_ls.goodness_of_fit_all` +both present, holding the same value. Belt-and-braces, doubles the +surface area of every saved file, and the two values can drift. + +### C. Adopt IUCr tags only for tags we already need + +Add the R-factor fields (§2) under our own `_fit_result.*` prefix. +Smaller diff, fixes the missing-field gap but not the naming divergence. +Pick if IUCr interop is genuinely not a goal. diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index 46e85a912..1e0bfe340 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -1782,6 +1782,49 @@ optional-diagnostics components. --- +## 105. 🟢 Remove Orphaned Fit-Result Reset Helper + +**Type:** Cleanup **Source:** `minimizer-input-output-split` review 6. + +`Analysis._clear_fit_result_projection` is a private method with no +callers after `_clear_persisted_fit_state` switched to replacing +`self._fit_result` with a fresh paired result instance. + +**TODOs:** + +- [analysis.py](src/easydiffraction/analysis/analysis.py#L1217) + +**Fix:** delete the unused helper, or reintroduce a caller only if a +future fit-result reset path genuinely needs to preserve the active +instance. + +**Depends on:** nothing. + +--- + +## 106. 🟢 Document `FitResultBase.result_kind` Default Rationale + +**Type:** Code readability **Source:** `minimizer-input-output-split` +review 6. + +Most `FitResultBase` descriptors use `default=None, allow_none=True` so +pre-fit CIF output serializes unknown values as `?`. `result_kind` +intentionally keeps a valid enum default because it drives deterministic +versus Bayesian projection handling, but that exception is not +documented in code. + +**TODOs:** + +- [base.py](src/easydiffraction/analysis/categories/fit_result/base.py#L44) + +**Fix:** add a short code comment near the `result_kind` descriptor +explaining why it keeps a concrete default while unknown result values +use `None`. + +**Depends on:** nothing. + +--- + ## Summary | # | Issue | Severity | Type | @@ -1869,3 +1912,5 @@ optional-diagnostics components. | 91 | Disable TODO checks in CodeFactor PRs | 🟢 Low | CI / Tooling | | 92 | Make `save()` respect verbosity | 🟢 Low | UX | | 93 | Eliminate flicker in live progress tables | 🟡 Med | UX | +| 105 | Remove orphaned fit-result reset helper | 🟢 Low | Cleanup | +| 106 | Document `FitResultBase.result_kind` default | 🟢 Low | Code readability | diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index a29064bdb..1f0663352 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -47,10 +47,15 @@ │ │ │ └── 🏷️ class FitParametersFactory │ │ ├── 📁 fit_result │ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ └── 🏷️ class FitResultBase +│ │ │ ├── 📄 bayesian.py +│ │ │ │ └── 🏷️ class BayesianFitResult │ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class FitResult -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class FitResultFactory +│ │ │ ├── 📄 factory.py +│ │ │ │ └── 🏷️ class FitResultFactory +│ │ │ └── 📄 lsq.py +│ │ │ └── 🏷️ class LeastSquaresFitResult │ │ ├── 📁 fitting_mode │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index dbe4e8f6d..e0a529800 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -29,8 +29,11 @@ │ │ │ └── 📄 factory.py │ │ ├── 📁 fit_result │ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ ├── 📄 bayesian.py │ │ │ ├── 📄 default.py -│ │ │ └── 📄 factory.py +│ │ │ ├── 📄 factory.py +│ │ │ └── 📄 lsq.py │ │ ├── 📁 fitting_mode │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py diff --git a/docs/dev/plans/minimizer-input-output-split.md b/docs/dev/plans/minimizer-input-output-split.md new file mode 100644 index 000000000..0d9361a8a --- /dev/null +++ b/docs/dev/plans/minimizer-input-output-split.md @@ -0,0 +1,674 @@ +# Plan: Minimizer Input/Output Split + +> This plan follows +> [`.github/copilot-instructions.md`](../../../.github/copilot-instructions.md). +> No deliberate exceptions. + +## ADR + +Implements +[`docs/dev/adrs/suggestions/minimizer-input-output-split.md`](../adrs/suggestions/minimizer-input-output-split.md). +This plan promotes that ADR from Suggestion → Accepted during +implementation (step P1.16). + +Affected ADRs that this plan amends (per the ADR's §"ADRs amended"): + +- [`accepted/minimizer-category-consolidation.md`](../adrs/accepted/minimizer-category-consolidation.md) + — §1 becomes a partial rule; §"Alternatives Considered → D" records + the reversal. +- [`accepted/analysis-cif-fit-state.md`](../adrs/accepted/analysis-cif-fit-state.md) + — §"Minimizer fit projection" rewritten for the settings-only + `_minimizer.*` / outputs-on-`_fit_result.*` shape. +- [`accepted/runtime-fit-results.md`](../adrs/accepted/runtime-fit-results.md) + — closing paragraph references this ADR alongside the existing two. +- [`accepted/switchable-category-owned-selectors.md`](../adrs/accepted/switchable-category-owned-selectors.md) + — §1 gains the documented "fully-determined paired category" + exception. +- [`accepted/display-ux.md`](../adrs/accepted/display-ux.md) — + `project.display.fit.results()` prints a "Settings used" block. + +## Branch and PR + +- Branch: `minimizer-input-output-split` (continued from the branch the + ADR was drafted on). Do not push unless asked. +- Each step in §"Implementation steps (Phase 1)" must be staged with + explicit paths and committed locally **before** moving to the next + step. See `.github/copilot-instructions.md` → **Commits**. +- After P1.17, stop and wait for the user review gate before starting + Phase 2. + +## Decisions already made (from the ADR) + +1. `analysis.minimizer` holds **writable user settings only**. +2. `analysis.fit_result` becomes a class hierarchy paired with + `analysis.minimizer`. `FitResultBase` carries common fields; + `LeastSquaresFitResult` and `BayesianFitResult` add family-specific + ones. +3. `fit_result` is **not a user-facing switchable category**. No + `fit_result.type`, no `fit_result.show_supported()`. The owner's + `_swap_minimizer` hook installs both the minimizer and the paired + `fit_result` atomically. +4. Pairing rule is encoded on the minimizer base classes: + `LeastSquaresMinimizerBase._fit_result_class = LeastSquaresFitResult`, + `BayesianMinimizerBase._fit_result_class = BayesianFitResult`. +5. `objective_value` (raw χ²) and `reduced_chi_square` are distinct + fields, both kept on `LeastSquaresFitResult`. +6. `credible_interval_inner` / `credible_interval_outer` stay on the + output side (`BayesianFitResult`) at the fixed `0.68` / `0.95` + values. User-configurable levels deferred to a follow-on ADR. +7. The display extension lives under `project.display.fit.results()`; no + new `Analysis`-level display method is added. +8. Beta posture: hard cutover, no shims, no deprecation warnings. + Tutorials and saved fixtures regenerate. + +## Open questions + +- **Existing `analysis.fit_result.from_cif` parameter ordering.** After + this split, the CIF restore for `_fit_result.*` must run **after** + `_minimizer.*` is read so the paired class is known before the result + descriptors load. The current `_restore_*` order in + [`serialize.py`](../../../src/easydiffraction/io/cif/serialize.py) + reads `_minimizer.*` first via `_swap_minimizer`, then iterates the + rest. P1.6 must ensure `_fit_result` is included in the iteration only + after the swap has installed the paired class. Confirm during P1.6 + implementation. +- **Posterior-summary code path that currently writes + `_set_credible_interval_*` on `minimizer`.** After P1.11, the setters + move to `BayesianFitResult`. The `_store_posterior_fit_projection` + method in + [`analysis.py`](../../../src/easydiffraction/analysis/analysis.py) + must be updated to call + `self._fit_result._set_credible_interval_inner(...)` instead of + `self.minimizer._set_*`. Verify the order of operations against the + test in + [`test_results_sidecar.py`](../../../tests/unit/easydiffraction/io/test_results_sidecar.py). + +## Concrete files likely to change + +### Created + +- `src/easydiffraction/analysis/categories/fit_result/base.py` — rename + existing `FitResult` class to `FitResultBase` (or extract a base). + Common output descriptors live here. +- `src/easydiffraction/analysis/categories/fit_result/lsq.py` — + `LeastSquaresFitResult` with LSQ-specific output descriptors. +- `src/easydiffraction/analysis/categories/fit_result/bayesian.py` — + `BayesianFitResult` with Bayesian-specific output descriptors, + including `credible_interval_inner` / `credible_interval_outer`. +- _(`src/easydiffraction/analysis/categories/fit_result/factory.py` + already exists; this plan extends it rather than creating it. See P1.4 + — the factory becomes a registration helper for the two new family + classes; the authoritative swap mechanism is the `_fit_result_class` + attribute on the paired minimizer base, not a factory lookup. The + factory is still useful for introspection / testing.)_ +- `tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py` +- `tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py` +- `tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py` +- `tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py` + +### Modified + +- `src/easydiffraction/analysis/categories/fit_result/__init__.py` — add + explicit imports for every new concrete class to trigger factory + registration. +- `src/easydiffraction/analysis/categories/fit_result/default.py` — + rewritten to import from the new family modules; the existing + `FitResult` is renamed to `FitResultBase` and absorbed. +- `src/easydiffraction/analysis/categories/minimizer/base.py` — add + `_fit_result_class: ClassVar[type]` declaration. +- `src/easydiffraction/analysis/categories/minimizer/lsq_base.py` — set + `_fit_result_class = LeastSquaresFitResult`; **remove** + `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, + `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, + `correlation_available`, `runtime_seconds`, `iterations_performed`, + `exit_reason` from descriptor declarations and from + `_result_descriptor_names`. `_setting_descriptor_names` stays + `('max_iterations',)`. +- `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py` — + set `_fit_result_class = BayesianFitResult`; remove `runtime_seconds`, + `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, + `credible_interval_outer`, `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior` from descriptor + declarations and from `_result_descriptor_names`. + `_setting_descriptor_names` keeps the seven Bayesian inputs. +- `src/easydiffraction/analysis/analysis.py` — extend `_swap_minimizer` + to also instantiate the paired `fit_result` via the minimizer's + `_fit_result_class`; add `analysis.fit_result` property; route every + `self._minimizer._set_*` result-writer call in + `_store_least_squares_result_projection` / + `_store_posterior_fit_projection` / + `_restore_fit_results_from_projection` to `self._fit_result._set_*` + instead. +- `src/easydiffraction/io/cif/serialize.py` — emit/read `_fit_result.*` + from the paired class; remove the removed `_minimizer.*` output tags + from the serialise/deserialise paths. Update the legacy-tag rejection + message. +- `src/easydiffraction/project/display.py` — extend + `project.display.fit.results()` to print a "Settings used" block + populated from `analysis.minimizer.*` above the existing result + tables. +- All tutorials referencing `analysis.minimizer.` (e.g. + `runtime_seconds`, `gelman_rubin_max`, `objective_value`) → + `analysis.fit_result.`. List enumerated at P1.15 start + via `git grep`. +- Tests reading `analysis.minimizer.` → migrate to + `analysis.fit_result.`. P2.1 enumerates. + +### Deleted + +- None. The existing `fit_result/default.py` is rewritten in place; the + existing `FitResult` class becomes `FitResultBase`. + +## Implementation steps (Phase 1) + +Mark `[x]` as each step lands. + +- [x] **P1.1 — Rename `FitResult` to `FitResultBase`; add reset hooks; + update every import site.** In + `src/easydiffraction/analysis/categories/fit_result/default.py`, + rename the class. The factory `@register` decorator stays on the + renamed class so the default-tag lookup keeps working until P1.4 + extends the factory. + + Add two class-level hooks to `FitResultBase` matching the + `MinimizerCategoryBase` shape introduced by the consolidation + work + ([`minimizer/base.py:69-74`](../../../src/easydiffraction/analysis/categories/minimizer/base.py)): + + ```python + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'success', 'message', 'iterations', + 'fitting_time', 'reduced_chi_square', 'result_kind', + ) + + def _reset_result_descriptors(self) -> None: + """Reset fit-result descriptors to declared defaults.""" + for name in self._result_descriptor_names: + descriptor = getattr(self, name) + if isinstance(descriptor, GenericDescriptorBase): + descriptor.value = descriptor._value_spec.default_value() + ``` + + `LeastSquaresFitResult` (P1.2) and `BayesianFitResult` (P1.3) + then add their own field names to `_result_descriptor_names` + so the inherited helper resets every relevant descriptor. + + This must land in P1.1 because P1.6 retargets + `_clear_minimizer_result_projection` (renamed + `_clear_fit_result_projection`) to call + `self.fit_result._reset_result_descriptors()`, and that method + must exist on `FitResultBase` before the swap is wired. + + Update every package-level import that referenced the old + name. `git grep -nP '\bFitResult\b' src/ tests/` lists the + sites at plan time: + + - `src/easydiffraction/analysis/__init__.py` (line 14 today) + - `src/easydiffraction/analysis/categories/__init__.py` + (line 14 today) + - `src/easydiffraction/analysis/categories/fit_result/__init__.py` + - `src/easydiffraction/analysis/analysis.py` (import line 18; + type annotation on the `fit_result` property at line 432; + `self._fit_result = FitResult()` at line 483; same + construction at line 1208) + + All four `FitResult` import/annotation/construction sites in + `analysis.py` become `FitResultBase` after this step. The two + `self._fit_result = FitResult()` construction sites (init and + `_clear_persisted_fit_state`) become + `FitResultBase()` temporarily; P1.6 retargets them to the + paired class. + + Re-run `git grep -nP '\bFitResult\b' src/` at the end of this + step — every remaining hit must be the renamed class name or a + module path, not the old bare class. Tests are migrated by + P2.1. + + Commit: `Rename FitResult to FitResultBase, add reset hooks` + +- [x] **P1.2 — Add `LeastSquaresFitResult` class.** New file + `src/easydiffraction/analysis/categories/fit_result/lsq.py`. + `LeastSquaresFitResult(FitResultBase)` declares: `objective_name`, + `objective_value`, `n_data_points`, `n_parameters`, + `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, + `correlation_available`, `exit_reason`. **All defaults are `None` + with `allow_none=True`**, matching the consolidation cleanup that + previously moved LSQ outputs off `0` / `false` / `''` so a pre-fit + CIF emits `?` rather than a value that looks like a degenerate + result. This applies to numeric, integer-like, string, and bool + fields alike; the descriptor helpers in + `LeastSquaresMinimizerBase` + ([`lsq_base.py`](../../../src/easydiffraction/analysis/categories/minimizer/lsq_base.py)) + that currently produce these descriptors are the model — they can + be lifted into `LeastSquaresFitResult` verbatim before being + removed from `lsq_base.py` at P1.9. Declare + `_expected_descriptor_names`, `_result_descriptor_names` for + parity with the minimizer hierarchy. Tests deferred to Phase 2. + Commit: `Add LeastSquaresFitResult class` + +- [x] **P1.3 — Add `BayesianFitResult` class.** New file + `src/easydiffraction/analysis/categories/fit_result/bayesian.py`. + `BayesianFitResult(FitResultBase)` declares: + `point_estimate_name`, `sampler_completed`, + `credible_interval_inner` (default `0.68`), + `credible_interval_outer` (default `0.95`), + `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior`. Declare + `_expected_descriptor_names`, `_result_descriptor_names`. Tests + deferred to Phase 2. Commit: `Add BayesianFitResult class` + +- [x] **P1.4 — Register fit-result classes with the existing + `FitResultFactory`.** The factory already exists at + [`src/easydiffraction/analysis/categories/fit_result/factory.py`](../../../src/easydiffraction/analysis/categories/fit_result/factory.py) + and currently registers only the default common class. Update it + to also register `LeastSquaresFitResult` and `BayesianFitResult` + with their family tags. Update + `src/easydiffraction/analysis/categories/fit_result/__init__.py` + to explicitly import every concrete class (so registration fires + on package import, per the repo's standard pattern). + + **Authoritative mechanism:** `Analysis._swap_minimizer` + constructs the paired fit-result via the minimizer's + `_fit_result_class` attribute (P1.5), not via a factory + lookup. The factory is kept as a registration helper for + introspection and testing; do not add a public selector surface + (`type`, `show_supported`) since `fit_result` is internally + paired, per ADR §1. Commit: + `Register fit-result family classes with factory` + +- [x] **P1.5 — Declare `_fit_result_class` on minimizer bases.** In + `src/easydiffraction/analysis/categories/minimizer/lsq_base.py`, + add `_fit_result_class: ClassVar[type] = LeastSquaresFitResult`. + In + `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py`, + add `_fit_result_class: ClassVar[type] = BayesianFitResult`. Add + the matching declaration to + `src/easydiffraction/analysis/categories/minimizer/base.py` with + `_fit_result_class: ClassVar[type] = FitResultBase` as a safety + fallback (no concrete minimizer instantiates the bare base, but + `_swap_minimizer` reads through this attribute). Commit: + `Declare paired _fit_result_class on minimizer bases` + +- [x] **P1.6 — Wire `Analysis._swap_minimizer` to install both + instances, and update every `_fit_result` reset path.** In + `src/easydiffraction/analysis/analysis.py`: + - `__init__` constructs the initial `_fit_result` from the default + minimizer's `_fit_result_class`: + `self._fit_result = self._minimizer._fit_result_class()`. The line + 483 `self._fit_result = FitResultBase()` (after P1.1) is replaced. + - `_replace_minimizer` constructs + `self._fit_result = new_minimizer._fit_result_class()` after the new + minimizer is created. The old `fit_result` is detached + (`_parent = None`) before being replaced. + - `_clear_persisted_fit_state` (line 1204 today) currently calls + `self._clear_minimizer_result_projection()` and then + `self._fit_result = FitResult()`. After P1.1 + the split, both lines + must change: + - `self._fit_result = self.minimizer._fit_result_class()` replaces + the bare `FitResultBase()` construction. This keeps the paired + class invariant whenever the persisted state is reset. + - `self._clear_minimizer_result_projection()` currently calls + `self.minimizer._reset_result_descriptors()`. After P1.9/P1.10 + remove the result descriptors from the minimizer, this method + becomes a no-op. **Retarget it to + `self.fit_result._reset_result_descriptors()`** and rename it to + `_clear_fit_result_projection`. Update the call sites (line 1204; + potentially others — `git grep` confirms). + - Add `analysis.fit_result` read-only property + (`return self._fit_result`). Type annotation: `FitResultBase` (the + family classes inherit from it). + - Wire `self._fit_result._parent = self` in + `_attach_category_parents`. Every `_fit_result` reassignment in the + methods above must also set `_parent` on the new instance. + + Verification at the end of this step: + + ``` + git grep -nE 'self\._fit_result\s*=' src/easydiffraction/analysis/analysis.py + ``` + + Every match must construct via `self.minimizer._fit_result_class()` + (or `new_minimizer._fit_result_class()` in `_replace_minimizer`), not + a bare class name. There must be no remaining + `self._fit_result = FitResultBase()` after this step. + + Commit: `Wire fit_result swap and reset paths to paired class` + +- [x] **P1.7 — Route LSQ result writers to `fit_result`.** In + `src/easydiffraction/analysis/analysis.py`, + `_store_least_squares_result_projection` currently writes to + `self.minimizer._set_objective_name(...)` etc. Reroute every such + call to `self.fit_result._set_*`. Same for + `_restore_fit_results_from_projection`'s LSQ branch (it reads + `self.minimizer.objective_name.value` etc. — change to + `self.fit_result..value`). Commit: + `Route LSQ result writers to fit_result` + +- [x] **P1.8 — Route Bayesian result writers to `fit_result`.** Same + treatment for `_store_posterior_fit_projection` and the Bayesian + branch of `_restore_fit_results_from_projection`. Includes the + `_set_credible_interval_*` calls — they now target + `self.fit_result._set_credible_interval_*`. Commit: + `Route Bayesian result writers to fit_result` + +- [x] **P1.9 — Remove output fields from LSQ minimizer base.** In + `src/easydiffraction/analysis/categories/minimizer/lsq_base.py`, + delete the descriptor declarations and properties for + `objective_name`, `objective_value`, `n_data_points`, + `n_parameters`, `n_free_parameters`, `degrees_of_freedom`, + `covariance_available`, `correlation_available`, + `runtime_seconds`, `iterations_performed`, `exit_reason`. Remove + these names from `_expected_descriptor_names` and + `_result_descriptor_names`. `_setting_descriptor_names` stays + `('max_iterations',)`. After this step, `_result_descriptor_names` + on `LeastSquaresMinimizerBase` is `()` and + `_reset_result_descriptors()` is a no-op on every LSQ minimizer — + confirming the P1.6 retarget of + `_clear_minimizer_result_projection` to operate on + `self.fit_result` is the correct call site. + + Note: `optimizer_name` and `method_name` were already removed + by the consolidation work (`_engine_metadata` dict replaces + them); this step is the bulk removal of the remaining LSQ + outputs. + + Commit: `Remove LSQ output descriptors from minimizer base` + +- [x] **P1.10 — Remove duplicate fields from Bayesian minimizer base.** + In + `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py`, + delete the descriptor declarations and properties for + `runtime_seconds`, `point_estimate_name`, `sampler_completed`, + `credible_interval_inner`, `credible_interval_outer`, + `acceptance_rate_mean`, `gelman_rubin_max`, + `effective_sample_size_min`, `best_log_posterior`. Remove these + names from `_expected_descriptor_names` and + `_result_descriptor_names`. The `_setting_descriptor_names` tuple + keeps the seven Bayesian inputs. + + Commit: `Remove Bayesian output descriptors from minimizer base` + +- [x] **P1.11 — Update CIF emit/read for the split.** In + `src/easydiffraction/io/cif/serialize.py`: + + **No category-list reordering is performed in this step.** Neither + `Analysis._serializable_categories()` nor + `Analysis._fit_state_categories()` is restructured. `fit_result` stays + conditionally included by `_fit_state_categories()` only when + `self._has_persisted_fit_state()` is true — exactly as today. Pre-fit + projects continue to emit no `_fit_result.*` block. + + The only changes in this step are content updates inside the existing + emit/read flow: + - `_minimizer.*` emit/read continues to handle settings only (the + minimizer category's `from_cif` walks its remaining descriptors + after P1.9 / P1.10 removed the output descriptors). + - `_fit_result.*` emit/read picks up the new family-specific + descriptors automatically because P1.6 wires the paired class + (`LeastSquaresFitResult` or `BayesianFitResult`) onto + `self._fit_result`. The existing + `analysis.fit_result.from_cif(block)` call inside + `_restore_common_fit_state` + ([`serialize.py:590`](../../../src/easydiffraction/io/cif/serialize.py)) + reads `_fit_result.*` tags into the already-paired class — no + reordering, no new call. + - The read-side already restores `minimizer.type` first + ([`serialize.py:553-555`](../../../src/easydiffraction/io/cif/serialize.py)), + so the paired-class swap fires before `fit_result.from_cif` runs. No + code change is required here. + - Update the legacy-tag rejection message in + `_raise_for_legacy_analysis_tags` to include the now-removed + `_minimizer.` tags (e.g. `_minimizer.runtime_seconds`, + `_minimizer.gelman_rubin_max`) as legacy markers that should raise a + clear error rather than load silently. + + Commit: `Serialize fit outputs to _fit_result.* tags` + +- [x] **P1.12 — Confirm `_fit_state_categories` returns the paired + `fit_result`.** In `src/easydiffraction/analysis/analysis.py`, + `_fit_state_categories()` already returns + `[self.fit_parameters, self.fit_result, self.fit_parameter_correlations]` + when persisted fit state exists. After P1.6 wires the paired-class + construction, `self.fit_result` is automatically the paired + `LeastSquaresFitResult` / `BayesianFitResult` instance — no method + body change is needed. The dead branch in `_fit_state_categories` + (review-9 finding F4, open issue #101) can be cleaned up here + since this step is already reading the function. The plan does not + require the cleanup; if taken, mention "closes #101" in the commit + message. + + Commit: `Confirm fit_result paired instance flows through serializer` + +- [x] **P1.13 — Update `project.display.fit.results()` to add a + "Settings used" block.** In + `src/easydiffraction/project/display.py`, extend the existing + results-display method to print, above the current tables, a + one-section table titled "Settings used" populated from + `analysis.minimizer.*`. Use the same `render_table` machinery the + rest of the display facade uses. Commit: + `Add settings-used block to fit.results display` + +- [x] **P1.14 — Amend the five accepted ADRs listed in §"ADR".** For + each, apply the matching paragraph from the ADR's §"ADRs amended" + section: + - `minimizer-category-consolidation.md` — §1 partial-rule + qualification; §"Alternatives Considered → D" reversal record. + - `analysis-cif-fit-state.md` — §"Minimizer fit projection" rewrite. + - `runtime-fit-results.md` — closing-paragraph reference. + - `switchable-category-owned-selectors.md` — §1 paired-category + exception paragraph. + - `display-ux.md` — `project.display.fit.results()` settings-block + note. + - Update `docs/dev/adrs/index.md` to add the new ADR row under + "Accepted" (per P1.16 promotion). + + Commit: `Amend affected ADRs for minimizer input/output split` + +- [x] **P1.15 — Update tutorials.** `git grep` `docs/docs/tutorials/` + for `analysis.minimizer.` references and rewrite + each per the migration table below. The two **collapsed** rows + target existing common fields on `FitResultBase` (already written + by the existing common projection writer); they are not 1:1 + renames of the old setter/getter name. The other rows are + moved-but-keep-the-name relocations. + + | Old (removed at P1.9 / P1.10) | New | Notes | + | --- | --- | --- | + | `analysis.minimizer.runtime_seconds` | `analysis.fit_result.fitting_time` | Collapsed onto existing common field; setter remains `fit_result._set_fitting_time(...)` (already in `FitResultBase`). | + | `analysis.minimizer.iterations_performed` | `analysis.fit_result.iterations` | Collapsed onto existing common field; setter remains `fit_result._set_iterations(...)`. | + | `analysis.minimizer.objective_name` | `analysis.fit_result.objective_name` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.objective_value` | `analysis.fit_result.objective_value` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.n_data_points` | `analysis.fit_result.n_data_points` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.n_parameters` | `analysis.fit_result.n_parameters` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.n_free_parameters` | `analysis.fit_result.n_free_parameters` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.degrees_of_freedom` | `analysis.fit_result.degrees_of_freedom` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.covariance_available` | `analysis.fit_result.covariance_available` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.correlation_available` | `analysis.fit_result.correlation_available` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.exit_reason` | `analysis.fit_result.exit_reason` | Moved to `LeastSquaresFitResult`. | + | `analysis.minimizer.point_estimate_name` | `analysis.fit_result.point_estimate_name` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.sampler_completed` | `analysis.fit_result.sampler_completed` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.credible_interval_inner` | `analysis.fit_result.credible_interval_inner` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.credible_interval_outer` | `analysis.fit_result.credible_interval_outer` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.acceptance_rate_mean` | `analysis.fit_result.acceptance_rate_mean` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.gelman_rubin_max` | `analysis.fit_result.gelman_rubin_max` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.effective_sample_size_min` | `analysis.fit_result.effective_sample_size_min` | Moved to `BayesianFitResult`. | + | `analysis.minimizer.best_log_posterior` | `analysis.fit_result.best_log_posterior` | Moved to `BayesianFitResult`. | + + Run `pixi run notebook-prepare` to regenerate the `.ipynb` + files. + + Verification grep (must return empty against + `docs/docs/tutorials/`): + + ``` + git grep -nE 'analysis\.minimizer\.(runtime_seconds|iterations_performed|objective_value|objective_name|n_data_points|n_parameters|n_free_parameters|degrees_of_freedom|covariance_available|correlation_available|exit_reason|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' docs/docs/tutorials/ + ``` + + Commit: `Update tutorials to read outputs from fit_result` + +- [x] **P1.16 — Promote ADR + update index.** + - `git mv docs/dev/adrs/suggestions/minimizer-input-output-split.md docs/dev/adrs/accepted/minimizer-input-output-split.md`. + Flip the Status header to `Accepted`. + - Move the seven `_reply-N.md` and seven `_review-N.md` siblings: keep + them next to the ADR if the project convention preserves history + under `accepted/`; delete them if the convention is to drop the + deliberation artefacts on promotion (the + `switchable-category-owned-selectors` precedent deleted them). Per + the precedent, delete on promotion. + - Update `docs/dev/adrs/index.md` — move the row for this ADR from + Suggestion → Accepted. + + Commit: `Promote minimizer-input-output-split ADR` + +- [x] **P1.17 — Phase 1 review gate.** No code change. Re-run the P1.15 + tutorial grep against `src/`, `docs/docs/tutorials/`, and + `tests/`. The `src/` and `docs/docs/tutorials/` scopes must return + empty. The `tests/` sweep is deferred to P2.1, which migrates the + tests. Then stop and request user review. After approval, proceed + to Phase 2. + +## Verification (Phase 2) + +Each command captures its log with a zsh-safe exit-code variable as +required by `.github/copilot-instructions.md` → **Workflow**. + +- [x] **P2.1 — Migrate existing tests off the removed minimizer output + fields.** `git grep` `tests/` for the same patterns as P1.15. + Apply the same migration table from P1.15 — including the two + collapsed rows (`runtime_seconds` → `fitting_time`, + `iterations_performed` → `iterations`) where the setter name also + changes. Examples: + + - Reader rewrite: + `analysis.minimizer.gelman_rubin_max` → + `analysis.fit_result.gelman_rubin_max`. + - Reader rewrite with collapse: + `analysis.minimizer.runtime_seconds` → + `analysis.fit_result.fitting_time`. + - Setter rewrite (moved-but-kept name): + `analysis.minimizer._set_gelman_rubin_max(...)` → + `analysis.fit_result._set_gelman_rubin_max(...)`. + - Setter rewrite (collapsed name): + `analysis.minimizer._set_runtime_seconds(...)` → + `analysis.fit_result._set_fitting_time(...)`. + + Layout check: + + ``` + pixi run test-structure-check > /tmp/easydiffraction-test-structure-check.log 2>&1; \ + test_structure_check_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-test-structure-check.log; \ + exit $test_structure_check_exit_code + ``` + + Final stale-reference grep (all four must return empty): + + ``` + git grep -nE 'analysis\.minimizer\.(runtime_seconds|iterations_performed|objective_value|objective_name|n_data_points|n_parameters|n_free_parameters|degrees_of_freedom|covariance_available|correlation_available|exit_reason|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' src/ docs/docs/tutorials/ tests/ + git grep -nE '_minimizer\.(runtime_seconds|iterations_performed|objective_value|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' src/ docs/docs/tutorials/ tests/ + ``` + +- [x] **P2.2 — Add unit tests for new modules.** New tests under + `tests/unit/easydiffraction/analysis/categories/fit_result/`: + - `test_base.py` — `FitResultBase` defaults, + `_reset_result_descriptors`. + - `test_lsq.py` — `LeastSquaresFitResult` defaults; CIF round-trip of + LSQ outputs. + - `test_bayesian.py` — `BayesianFitResult` defaults including the + fixed credible interval levels; CIF round-trip. + - `test_factory.py` — pairing rule via + `LeastSquaresMinimizerBase._fit_result_class` and + `BayesianMinimizerBase._fit_result_class`. + + Layout check: + + ``` + pixi run test-structure-check > /tmp/easydiffraction-test-structure-check.log 2>&1; \ + test_structure_check_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-test-structure-check.log; \ + exit $test_structure_check_exit_code + ``` + +- [x] **P2.3 — Auto-fixes and static checks.** + + ``` + pixi run fix > /tmp/easydiffraction-fix.log 2>&1; \ + fix_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-fix.log; \ + exit $fix_exit_code + ``` + + Then: + + ``` + pixi run check > /tmp/easydiffraction-check.log 2>&1; \ + check_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-check.log; \ + exit $check_exit_code + ``` + + Iterate `pixi run check` until clean. Do not raise lint + thresholds — refactor instead. + +- [x] **P2.4 — Unit tests.** + + ``` + pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; \ + unit_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-unit-tests.log; \ + exit $unit_tests_exit_code + ``` + +- [x] **P2.5 — Integration tests.** + + ``` + pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; \ + integration_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-integration-tests.log; \ + exit $integration_tests_exit_code + ``` + +- [x] **P2.6 — Script tests.** + + ``` + pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; \ + script_tests_exit_code=$?; \ + tail -n 200 /tmp/easydiffraction-script-tests.log; \ + exit $script_tests_exit_code + ``` + + This regenerates `tmp/tutorials/projects/*` fixtures with the + new CIF layout (`_minimizer.*` settings-only, + `_fit_result.*` outputs). + +## Suggested Pull Request + +**Title:** Split minimizer settings from fit-result outputs + +**Description (user-facing):** + +`analysis.minimizer` now holds only the settings you can change — +`sampling_steps`, `max_iterations`, and the other input knobs. Once a +fit completes, every output the project records (wall time, χ², the +Bayesian diagnostics, the LSQ counters) lives on a paired +`analysis.fit_result`. The pairing happens automatically when you change +minimizer type, so users never see a separate result selector. + +`project.display.fit.results()` now prints a "Settings used" block above +the existing result tables, so the settings that produced the fit and +the outputs the fit produced are visible side-by-side without having to +open two namespaces. + +The CIF layout follows the same split: `_minimizer.*` holds settings +only, `_fit_result.*` holds outputs. Saved projects from the previous +layout do not load unchanged (the project is in beta; no legacy shims). +Tutorials and saved-fixture regeneration land in this PR. + +Incidental cleanup also bundled in this branch: the unused +`essdiffraction` development dependency is removed, and a handful of +unrelated functions are split into helpers to satisfy the project's +complexity thresholds during Phase 2 verification: +`singleton.ConstraintsHandler.apply_constraints`, +`analysis.sequential._fit_worker`, +`display.plotting._posterior_predictive_*`, and +`calculators.{crysfml,pdffit}._calculate_pattern`. diff --git a/docs/docs/tutorials/ed-24.ipynb b/docs/docs/tutorials/ed-24.ipynb index 869a30919..4e77ed733 100644 --- a/docs/docs/tutorials/ed-24.ipynb +++ b/docs/docs/tutorials/ed-24.ipynb @@ -46,9 +46,7 @@ "cell_type": "code", "execution_count": null, "id": "3", - "metadata": { - "lines_to_next_cell": 1 - }, + "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", diff --git a/pixi.lock b/pixi.lock index c421cdaaa..e17dcaa8c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -184,7 +184,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -218,31 +217,22 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -260,14 +250,11 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -276,7 +263,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.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/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl @@ -288,8 +274,6 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -298,15 +282,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl @@ -317,38 +298,27 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/2c/3850985d4c64048dec7b826f8a803e135b52b11b4c81c9cd4326b1ca15ab/ncrystal_core-4.4.2-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/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 @@ -520,11 +490,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -554,37 +522,29 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.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/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -600,14 +560,11 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -617,7 +574,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.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/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 @@ -629,9 +585,7 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -639,18 +593,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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 @@ -658,36 +608,26 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/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 @@ -855,7 +795,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -863,7 +802,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl @@ -886,32 +824,24 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - 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/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -929,15 +859,12 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -946,7 +873,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.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/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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 @@ -959,8 +885,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/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 @@ -969,16 +893,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl @@ -989,37 +909,27 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.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/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1203,7 +1113,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -1213,7 +1122,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl @@ -1227,7 +1135,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/33/75/98a7eb100dc5cfd20b019046452f08d5e67dfbacc71d8f28763d32426fd3/spglib-2.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -1237,35 +1144,27 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-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/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-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/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.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/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -1283,14 +1182,11 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -1301,7 +1197,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-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 - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl @@ -1313,9 +1208,7 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ae/61/3c1ea8c10bf4f6bf83c33a7f5b4a3143f4cc1f979859dec5498b6cc31900/pycifrw-5.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -1324,12 +1217,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl @@ -1341,36 +1232,25 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/2c/3850985d4c64048dec7b826f8a803e135b52b11b4c81c9cd4326b1ca15ab/ncrystal_core-4.4.2-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/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/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl osx-arm64: @@ -1543,7 +1423,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/1d/9f9e30d76300b0150afaa8b37fab9a0194d44fd4f6b1e5038aca4a1440ed/crysfml-0.6.2-cp312-cp312-macosx_14_0_arm64.whl @@ -1579,32 +1458,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-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/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -1620,14 +1490,11 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -1638,7 +1505,6 @@ environments: - 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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl @@ -1650,9 +1516,7 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.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/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl @@ -1663,16 +1527,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/bd/8c/d4907ad4f6bdc5bf79462d8767728713a7b316918a7444df372958a0e417/spglib-2.6.0-cp312-cp312-macosx_11_0_arm64.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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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 @@ -1682,33 +1542,23 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.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/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl win-64: @@ -1875,12 +1725,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/c7/78200c18404ded028758b28b588aa1f4f3acd851271a74156a2a3db9eadf/crysfml-0.6.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl @@ -1908,32 +1756,24 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-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/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/6f/0c/8297c8d978c919ad6318011631a6123082d5da940da5f8612e75a247d739/diffpy_pdffit2-1.6.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -1952,16 +1792,12 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/82/b54e646be7b938fcbdda10030c6533bd2bb1a59930a1381cc83d6050a49c/spglib-2.6.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/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -1970,7 +1806,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.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/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl @@ -1984,10 +1819,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -1997,14 +1830,11 @@ environments: - 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/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl @@ -2013,34 +1843,24 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/f2/53be7a4ba5816e13c39be0f728facac4bcb39cf4903ceeec54b006511c8f/gemmi-0.7.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl py-314-env: @@ -2223,7 +2043,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -2257,31 +2076,22 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -2299,14 +2109,11 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -2315,7 +2122,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.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/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl @@ -2327,8 +2133,6 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -2337,15 +2141,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl @@ -2356,38 +2157,27 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/2c/3850985d4c64048dec7b826f8a803e135b52b11b4c81c9cd4326b1ca15ab/ncrystal_core-4.4.2-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/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 @@ -2559,11 +2349,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -2593,37 +2381,29 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.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/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -2639,14 +2419,11 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -2656,7 +2433,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.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/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 @@ -2668,9 +2444,7 @@ environments: - 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/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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 @@ -2678,18 +2452,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-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 @@ -2697,36 +2467,26 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/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 @@ -2894,7 +2654,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl @@ -2902,7 +2661,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-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/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-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/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl @@ -2925,32 +2683,24 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-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/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-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/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - 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/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-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/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-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/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.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/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl @@ -2968,15 +2718,12 @@ environments: - 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/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-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/96/5d/0c59079aa7ef34980a5925a06a90ad2b7c94e486c194b3527d557cabb042/cryspy-0.11.0-py3-none-any.whl @@ -2985,7 +2732,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.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/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-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 @@ -2998,8 +2744,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.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/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-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/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 @@ -3008,16 +2752,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-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/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - 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/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/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-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/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-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/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl @@ -3028,37 +2768,27 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-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/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-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/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-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/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-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/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.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/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-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/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.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/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-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/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -8066,7 +7796,6 @@ packages: - build ; extra == 'dev' - copier ; extra == 'dev' - docstripy ; extra == 'dev' - - essdiffraction ; extra == 'dev' - format-docstring ; extra == 'dev' - gitpython ; extra == 'dev' - interrogate ; extra == 'dev' @@ -8375,32 +8104,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 @@ -8455,15 +8158,6 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/12/aa/fb2a0649fdeef5ab7072d221e8f4df164098792c813af6c87e2581cfa860/mpltoolbox-26.2.0-py3-none-any.whl - name: mpltoolbox - version: 26.2.0 - sha256: cd2668db4216fc4d7c2ba37974961aa61445f1517527b645b6082930e35ba7f0 - requires_dist: - - matplotlib - - ipympl ; extra == 'test' - - pytest>=8.0 ; extra == 'test' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl name: interrogate version: 1.7.0 @@ -8576,32 +8270,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/c7/78200c18404ded028758b28b588aa1f4f3acd851271a74156a2a3db9eadf/crysfml-0.6.2-cp312-cp312-win_amd64.whl name: crysfml version: 0.6.2 @@ -8663,58 +8331,6 @@ packages: version: 0.0.4 sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 requires_python: '>=3.8' -- 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/1f/7e/c2cfe0bdbec1f5ce2bd92e03311038e1c491dfd54824606f38a61167a3f0/crysfml-0.6.2-cp314-cp314-macosx_14_0_arm64.whl name: crysfml version: 0.6.2 @@ -9144,32 +8760,6 @@ packages: name: distlib version: 0.4.0 sha256: 9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 -- pypi: https://files.pythonhosted.org/packages/33/75/98a7eb100dc5cfd20b019046452f08d5e67dfbacc71d8f28763d32426fd3/spglib-2.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: spglib - version: 2.6.0 - sha256: a8e9c34da1e2428c3a8bd4e209e5356d12d454d8ac54120d5ba4a437d3abe7ba - requires_dist: - - numpy>=1.20,<3 - - importlib-resources ; python_full_version < '3.10' - - typing-extensions>=4.9.0 ; python_full_version < '3.13' - - pytest ; extra == 'test' - - pyyaml ; extra == 'test' - - sphinx>=7.0 ; extra == 'docs' - - sphinxcontrib-bibtex>=2.5 ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - myst-parser>=2.0 ; extra == 'docs' - - linkify-it-py ; extra == 'docs' - - sphinx-tippy ; extra == 'docs' - - spglib[test] ; extra == 'test-cov' - - pytest-cov ; extra == 'test-cov' - - spglib[test] ; extra == 'test-benchmark' - - pytest-benchmark ; extra == 'test-benchmark' - - spglib[test] ; extra == 'dev' - - pre-commit ; extra == 'dev' - - spglib[docs] ; extra == 'doc' - - spglib[test] ; extra == 'testing' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl name: format-docstring version: 0.2.7 @@ -9434,11 +9024,6 @@ packages: requires_dist: - regex ; extra == 'extras' requires_python: '>=3.10' -- 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/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl name: asciichartpy version: 1.5.25 @@ -9493,13 +9078,6 @@ packages: - setuptools-scm>=7,<10 ; extra == 'dev' - setuptools>=64 ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/42/d9/27b13bc9419bf5dae02905b348f16ca827646cd76244ddd326f1a8139a6a/cyclebane-24.10.0-py3-none-any.whl - name: cyclebane - version: 24.10.0 - sha256: 902dd318667e4a222afc270cc5bc72c67d5d6047d2e0e1c36018885fb80f5e5d - requires_dist: - - networkx - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl name: mpmath version: 1.3.0 @@ -9545,58 +9123,6 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- 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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: crysfml version: 0.6.2 @@ -9685,38 +9211,6 @@ packages: - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - sqlcipher3-binary ; extra == 'sqlcipher' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl - name: dask - version: 2026.3.0 - sha256: be614b9242b0b38288060fb2d7696125946469c98a1c30e174883fd199e0428d - requires_dist: - - click>=8.1 - - cloudpickle>=3.0.0 - - fsspec>=2021.9.0 - - packaging>=20.0 - - partd>=1.4.0 - - pyyaml>=5.3.1 - - toolz>=0.12.0 - - importlib-metadata>=4.13.0 ; python_full_version < '3.12' - - numpy>=1.24 ; extra == 'array' - - dask[array] ; extra == 'dataframe' - - pandas>=2.0 ; extra == 'dataframe' - - pyarrow>=16.0 ; extra == 'dataframe' - - distributed>=2026.3.0,<2026.3.1 ; extra == 'distributed' - - bokeh>=3.1.0 ; extra == 'diagnostics' - - jinja2>=2.10.3 ; extra == 'diagnostics' - - dask[array,dataframe,diagnostics,distributed] ; extra == 'complete' - - pyarrow>=16.0 ; extra == 'complete' - - lz4>=4.3.2 ; extra == 'complete' - - pandas[test] ; extra == 'test' - - pytest ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-mock ; extra == 'test' - - pytest-rerunfailures ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-xdist ; extra == 'test' - - pre-commit ; extra == 'test' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl name: scipy version: 1.17.1 @@ -9852,22 +9346,6 @@ packages: - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - sqlcipher3-binary ; extra == 'sqlcipher' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - name: ipywidgets - version: 8.1.8 - sha256: ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e - requires_dist: - - comm>=0.1.3 - - ipython>=6.1.0 - - traitlets>=4.3.1 - - widgetsnbextension~=4.0.14 - - jupyterlab-widgets~=3.0.15 - - jsonschema ; extra == 'test' - - ipykernel ; extra == 'test' - - pytest>=3.6.0 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytz ; extra == 'test' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl name: ruff version: 0.15.14 @@ -10021,16 +9499,6 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5c/01/6cb4d63c6b6933be4b7945b2f64638336420f04ea71ca5b9a7539c008bc5/scippnexus-26.1.1-py3-none-any.whl - name: scippnexus - version: 26.1.1 - sha256: 899a0a5e71291b7809d902c17b6c74addf5a805397eabcec557491ff74eead12 - requires_dist: - - scipp>=24.2.0 - - scipy>=1.10.0 - - h5py>=3.12 - - pytest>=7.0 ; extra == 'test' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl name: pillow version: 12.2.0 @@ -10099,35 +9567,6 @@ packages: version: 0.5.2 sha256: 81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/63/9f/724f66a48309dd97a2ff58f491d6ffd925f35d1278a5e55dc9a5ac6a156b/scippneutron-26.5.0-py3-none-any.whl - name: scippneutron - version: 26.5.0 - sha256: e9cfad09b974867c6dc2a175cd2e575e06eaa951b2409e9ef863db237853bf99 - requires_dist: - - python-dateutil>=2.8 - - email-validator>=2 - - h5py>=3.12 - - lazy-loader>=0.4 - - mpltoolbox>=24.6.0 - - numpy>=1.20 - - plopp>=26.4.1 - - pydantic>=2 - - scipp>=25.8.0 - - scippnexus>=23.11.0 - - scipy>=1.7.0 - - scipp[all]>=25.8.0 ; extra == 'all' - - pooch>=1.5 ; extra == 'all' - - hypothesis>=6.100 ; extra == 'test' - - ipympl>0.9.0 ; extra == 'test' - - ipykernel>6.30 ; extra == 'test' - - pace-neutrons>=0.3 ; extra == 'test' - - pooch>=1.5 ; extra == 'test' - - psutil>=5.0 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' - - pytest-xdist>=3.0 ; extra == 'test' - - pythreejs>=2.4.1 ; extra == 'test' - - sciline>=25.1.0 ; extra == 'test' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl name: propcache version: 0.5.2 @@ -10401,27 +9840,6 @@ packages: version: 1.8.0 sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-py3-none-any.whl - name: ase - version: 3.28.0 - sha256: 0e24056302d7307b7247f90de281de15e3031c14cf400bedb1116c3b0d0e50b8 - requires_dist: - - numpy>=1.21.6 - - scipy>=1.8.1 - - matplotlib>=3.5.2 - - sphinx ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - sphinxcontrib-video ; extra == 'docs' - - sphinx-gallery ; extra == 'docs' - - pillow ; extra == 'docs' - - pytest>=7.4.0 ; extra == 'test' - - pytest-xdist>=3.2.0 ; extra == 'test' - - spglib>=1.9 ; extra == 'spglib' - - mypy ; extra == 'lint' - - ruff ; extra == 'lint' - - types-docutils ; extra == 'lint' - - types-pymysql ; extra == 'lint' - 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 @@ -10449,18 +9867,6 @@ packages: requires_dist: - diffpy-structure requires_python: '>=3.12,<3.15' -- pypi: https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl - name: partd - version: 1.4.2 - sha256: 978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f - requires_dist: - - locket - - toolz - - numpy>=1.20.0 ; extra == 'complete' - - pandas>=1.3 ; extra == 'complete' - - pyzmq ; extra == 'complete' - - blosc ; extra == 'complete' - requires_python: '>=3.9' - 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 @@ -10754,39 +10160,6 @@ packages: requires_dist: - typing-extensions>=4.14.1 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/8a/06/2c1bd1ee9eee3e65b0b6395dcf960e71b11576995eae3b4ab9ec63d89bea/essreduce-26.4.1-py3-none-any.whl - name: essreduce - version: 26.4.1 - sha256: 1758a18fffca9c7c2a6fa9547cf87bf45f9d52fc3ccbdffcf7524f71bc060424 - requires_dist: - - sciline>=25.11.0 - - scipp>=26.3.1 - - scippneutron>=25.11.1 - - scippnexus>=25.6.0 - - graphviz>=0.20 ; extra == 'test' - - ipywidgets>=8.1 ; extra == 'test' - - matplotlib>=3.10.7 ; extra == 'test' - - numba>=0.63 ; extra == 'test' - - pooch>=1.9.0 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' - - scipy>=1.14 ; extra == 'test' - - tof>=25.12.0 ; extra == 'test' - - autodoc-pydantic ; extra == 'docs' - - graphviz>=0.20 ; extra == 'docs' - - ipykernel ; extra == 'docs' - - ipython!=8.7.0 ; extra == 'docs' - - ipywidgets>=8.1 ; extra == 'docs' - - myst-parser ; extra == 'docs' - - nbsphinx ; extra == 'docs' - - numba>=0.63 ; extra == 'docs' - - plopp ; extra == 'docs' - - pydata-sphinx-theme>=0.14 ; extra == 'docs' - - sphinx>=7 ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-design ; extra == 'docs' - - tof>=25.12.0 ; extra == 'docs' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl name: lazy-loader version: '0.5' @@ -10816,16 +10189,6 @@ packages: version: 1.1.2 sha256: 1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl - name: traittypes - version: 0.2.3 - sha256: 49016082ce740d6556d9bb4672ee2d899cd14f9365f17cbb79d5d96b47096d4e - requires_dist: - - traitlets>=4.2.2 - - numpy ; extra == 'test' - - pandas ; extra == 'test' - - xarray ; extra == 'test' - - pytest ; extra == 'test' - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.py3-none-any.whl name: py3dmol version: 2.5.5 @@ -10847,32 +10210,6 @@ packages: - python-docs-theme ; extra == 'doc' - uncertainties[arrays,doc,test] ; extra == 'all' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/8f/82/b54e646be7b938fcbdda10030c6533bd2bb1a59930a1381cc83d6050a49c/spglib-2.6.0-cp312-cp312-win_amd64.whl - name: spglib - version: 2.6.0 - sha256: 86d0fd355689e58becd2cda609b03c3a0d9ad9d6f761cefd08b970db6f314eae - requires_dist: - - numpy>=1.20,<3 - - importlib-resources ; python_full_version < '3.10' - - typing-extensions>=4.9.0 ; python_full_version < '3.13' - - pytest ; extra == 'test' - - pyyaml ; extra == 'test' - - sphinx>=7.0 ; extra == 'docs' - - sphinxcontrib-bibtex>=2.5 ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - myst-parser>=2.0 ; extra == 'docs' - - linkify-it-py ; extra == 'docs' - - sphinx-tippy ; extra == 'docs' - - spglib[test] ; extra == 'test-cov' - - pytest-cov ; extra == 'test-cov' - - spglib[test] ; extra == 'test-benchmark' - - pytest-benchmark ; extra == 'test-benchmark' - - spglib[test] ; extra == 'dev' - - pre-commit ; extra == 'dev' - - spglib[docs] ; extra == 'doc' - - spglib[test] ; extra == 'testing' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl name: paginate version: 0.5.7 @@ -10953,26 +10290,6 @@ packages: - numpy>=1.22 ; extra == 'express' - kaleido>=1.1.0 ; extra == 'kaleido' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl - name: graphviz - version: '0.21' - sha256: 54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42 - requires_dist: - - build ; extra == 'dev' - - wheel ; extra == 'dev' - - twine ; extra == 'dev' - - flake8 ; extra == 'dev' - - flake8-pyproject ; extra == 'dev' - - pep8-naming ; extra == 'dev' - - tox>=3 ; extra == 'dev' - - pytest>=7,<8.1 ; extra == 'test' - - pytest-mock>=3 ; extra == 'test' - - pytest-cov ; extra == 'test' - - coverage ; extra == 'test' - - sphinx>=5,<7 ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - sphinx-rtd-theme>=0.2.5 ; extra == 'docs' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/91/6d/c00cb0d69d2e240c233c65b7f76d10522731156b28a2135bb97a05abc32c/easydiffraction-0.17.0-py3-none-any.whl name: easydiffraction version: 0.17.0 @@ -11146,49 +10463,6 @@ packages: - pycodestyle>=2.12.0 - tomli ; python_full_version < '3.11' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - name: networkx - version: 3.6.1 - sha256: d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 - requires_dist: - - asv ; extra == 'benchmarking' - - virtualenv ; extra == 'benchmarking' - - numpy>=1.25 ; extra == 'default' - - scipy>=1.11.2 ; extra == 'default' - - matplotlib>=3.8 ; extra == 'default' - - pandas>=2.0 ; extra == 'default' - - pre-commit>=4.1 ; extra == 'developer' - - mypy>=1.15 ; extra == 'developer' - - sphinx>=8.0 ; extra == 'doc' - - pydata-sphinx-theme>=0.16 ; extra == 'doc' - - sphinx-gallery>=0.18 ; extra == 'doc' - - numpydoc>=1.8.0 ; extra == 'doc' - - pillow>=10 ; extra == 'doc' - - texext>=0.6.7 ; extra == 'doc' - - myst-nb>=1.1 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - osmnx>=2.0.0 ; extra == 'example' - - momepy>=0.7.2 ; extra == 'example' - - contextily>=1.6 ; extra == 'example' - - seaborn>=0.13 ; extra == 'example' - - cairocffi>=1.7 ; extra == 'example' - - igraph>=0.11 ; extra == 'example' - - scikit-learn>=1.5 ; extra == 'example' - - iplotx>=0.9.0 ; extra == 'example' - - lxml>=4.6 ; extra == 'extra' - - pygraphviz>=1.14 ; extra == 'extra' - - pydot>=3.0.1 ; extra == 'extra' - - sympy>=1.10 ; extra == 'extra' - - build>=0.10 ; extra == 'release' - - twine>=4.0 ; extra == 'release' - - wheel>=0.40 ; extra == 'release' - - changelist==0.5 ; extra == 'release' - - pytest>=7.2 ; extra == 'test' - - pytest-cov>=4.0 ; extra == 'test' - - pytest-xdist>=3.0 ; extra == 'test' - - pytest-mpl ; extra == 'test-extras' - - pytest-randomly ; extra == 'test-extras' - requires_python: '>=3.11,!=3.14.1' - pypi: https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl name: h5py version: 3.16.0 @@ -11466,11 +10740,6 @@ packages: - pytest ; extra == 'testing' - tox ; extra == 'testing' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - name: jupyterlab-widgets - version: 3.0.16 - sha256: 45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl name: numpy version: 2.4.6 @@ -11501,23 +10770,6 @@ packages: - prettytable - ply - numpy -- pypi: https://files.pythonhosted.org/packages/af/46/661159ad844034ba8b3f4e0516215c41e4ee17db4213d13a82227670764f/sciline-25.11.1-py3-none-any.whl - name: sciline - version: 25.11.1 - sha256: 13c378287b8157e819b9b67d7e973c65bc6bdc545a3602d18204c365b0c336f9 - requires_dist: - - cyclebane>=24.6.0 - - pytest ; extra == 'test' - - pytest-randomly>=3 ; extra == 'test' - - dask ; extra == 'test' - - graphviz ; extra == 'test' - - jsonschema ; extra == 'test' - - numpy ; extra == 'test' - - pandas ; extra == 'test' - - pydantic ; extra == 'test' - - rich ; extra == 'test' - - rich ; extra == 'progress' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.whl name: gemmi version: 0.7.5 @@ -11751,33 +11003,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl - name: dnspython - version: 2.8.0 - sha256: 01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af - requires_dist: - - black>=25.1.0 ; extra == 'dev' - - coverage>=7.0 ; extra == 'dev' - - flake8>=7 ; extra == 'dev' - - hypercorn>=0.17.0 ; extra == 'dev' - - mypy>=1.17 ; extra == 'dev' - - pylint>=3 ; extra == 'dev' - - pytest-cov>=6.2.0 ; extra == 'dev' - - pytest>=8.4 ; extra == 'dev' - - quart-trio>=0.12.0 ; extra == 'dev' - - sphinx-rtd-theme>=3.0.0 ; extra == 'dev' - - sphinx>=8.2.0 ; extra == 'dev' - - twine>=6.1.0 ; extra == 'dev' - - wheel>=0.45.0 ; extra == 'dev' - - cryptography>=45 ; extra == 'dnssec' - - h2>=4.2.0 ; extra == 'doh' - - httpcore>=1.0.0 ; extra == 'doh' - - httpx>=0.28.0 ; extra == 'doh' - - aioquic>=1.2.0 ; extra == 'doq' - - idna>=3.10 ; extra == 'idna' - - trio>=0.30 ; extra == 'trio' - - wmi>=1.5.1 ; sys_platform == 'win32' and extra == 'wmi' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl name: pillow version: 12.2.0 @@ -11824,32 +11049,6 @@ packages: version: 1.2.0 sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/bd/8c/d4907ad4f6bdc5bf79462d8767728713a7b316918a7444df372958a0e417/spglib-2.6.0-cp312-cp312-macosx_11_0_arm64.whl - name: spglib - version: 2.6.0 - sha256: 83ea2e90addc7232017c793a32d94b47bc68040c595671d1cbb836ede4349510 - requires_dist: - - numpy>=1.20,<3 - - importlib-resources ; python_full_version < '3.10' - - typing-extensions>=4.9.0 ; python_full_version < '3.13' - - pytest ; extra == 'test' - - pyyaml ; extra == 'test' - - sphinx>=7.0 ; extra == 'docs' - - sphinxcontrib-bibtex>=2.5 ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - myst-parser>=2.0 ; extra == 'docs' - - linkify-it-py ; extra == 'docs' - - sphinx-tippy ; extra == 'docs' - - spglib[test] ; extra == 'test-cov' - - pytest-cov ; extra == 'test-cov' - - spglib[test] ; extra == 'test-benchmark' - - pytest-benchmark ; extra == 'test-benchmark' - - spglib[test] ; extra == 'dev' - - pre-commit ; extra == 'dev' - - spglib[docs] ; extra == 'doc' - - spglib[test] ; extra == 'testing' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: aiohttp version: 3.13.5 @@ -11868,32 +11067,6 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/bf/34/1fe99124be59579ebd24316522e1da780979c856977b142c0dcd878b0a2d/spglib-2.6.0.tar.gz - name: spglib - version: 2.6.0 - sha256: d66eda2ba00a1e14fd96ec9c3b4dbf8ab0fb3f124643e35785c71ee455b408eb - requires_dist: - - numpy>=1.20,<3 - - importlib-resources ; python_full_version < '3.10' - - typing-extensions>=4.9.0 ; python_full_version < '3.13' - - pytest ; extra == 'test' - - pyyaml ; extra == 'test' - - sphinx>=7.0 ; extra == 'docs' - - sphinxcontrib-bibtex>=2.5 ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - myst-parser>=2.0 ; extra == 'docs' - - linkify-it-py ; extra == 'docs' - - sphinx-tippy ; extra == 'docs' - - spglib[test] ; extra == 'test-cov' - - pytest-cov ; extra == 'test-cov' - - spglib[test] ; extra == 'test-benchmark' - - pytest-benchmark ; extra == 'test-benchmark' - - spglib[test] ; extra == 'dev' - - pre-commit ; extra == 'dev' - - spglib[docs] ; extra == 'doc' - - spglib[test] ; extra == 'testing' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-py3-none-any.whl name: docstring-parser-fork version: 0.0.14 @@ -11963,44 +11136,6 @@ packages: version: 1.5.0 sha256: bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c4/6d/82e65254354ba651dc966775270a9bbc02414a3eb3f1704e6c87dab2ea83/essdiffraction-26.5.1-py3-none-any.whl - name: essdiffraction - version: 26.5.1 - sha256: 8a6c779078c71be250714619214069221ab7968a69580d4e4d3f4b3e9a1a53ad - requires_dist: - - dask>=2022.1.0 - - essreduce>=26.4.0 - - graphviz - - numpy>=2 - - plopp>=26.2.0 - - pythreejs>=2.4.1 - - sciline>=25.4.1 - - scipp>=25.11.0 - - scippneutron>=26.3.0 - - scippnexus>=23.12.0 - - tof>=25.12.0 - - ncrystal[cif]>=4.1.0 - - spglib!=2.7 - - pandas>=2.1.2 ; extra == 'test' - - pooch>=1.5 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' - - ipywidgets>=8.1.7 ; extra == 'test' - - autodoc-pydantic ; extra == 'docs' - - ipykernel ; extra == 'docs' - - ipympl ; extra == 'docs' - - ipython!=8.7.0 ; extra == 'docs' - - myst-parser ; extra == 'docs' - - nbsphinx ; extra == 'docs' - - pandas ; extra == 'docs' - - pooch ; extra == 'docs' - - pydata-sphinx-theme>=0.14 ; extra == 'docs' - - sphinx ; extra == 'docs' - - sphinx-autodoc-typehints ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-design ; extra == 'docs' - - sphinxcontrib-bibtex ; extra == 'docs' - - pyarrow ; extra == 'docs' - requires_python: '>=3.11' - 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 @@ -12015,11 +11150,6 @@ packages: requires_dist: - colorama ; sys_platform == 'win32' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c7/6b/6c02f55c2ce2f137ccca0986be7dd89bea31d5bee4346b4377fa3b8586df/ncrystal_core-4.4.2-py3-none-win_amd64.whl - name: ncrystal-core - version: 4.4.2 - sha256: 9b28a90b63849e6a3a807a0a59f7c2ee57e4c64f5643b2dcb6a798ac8ccf666a - requires_python: '>=3.8' - 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 @@ -12034,11 +11164,6 @@ packages: - sphinx-rtd-theme ; extra == 'dev' - trustregion>=1.1 ; extra == 'trustregion' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/c7/ea/7988934c8e3e3418aa043f70421817df28d06aef50bfd85f5ad3ec6e70f1/ncrystal_core-4.4.2-py3-none-macosx_11_0_arm64.whl - name: ncrystal-core - version: 4.4.2 - sha256: b7e6101a6850aa18cf441825214381614db444ffcba648de8266fe1c4d1024ce - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl name: trove-classifiers version: 2026.5.22.10 @@ -12331,114 +11456,6 @@ packages: name: funcy version: '2.0' sha256: 53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0 -- pypi: https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl - name: fsspec - version: 2026.4.0 - sha256: 11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2 - requires_dist: - - adlfs ; extra == 'abfs' - - adlfs ; extra == 'adl' - - pyarrow>=1 ; extra == 'arrow' - - dask ; extra == 'dask' - - distributed ; extra == 'dask' - - pre-commit ; extra == 'dev' - - ruff>=0.5 ; extra == 'dev' - - numpydoc ; extra == 'doc' - - sphinx ; extra == 'doc' - - sphinx-design ; extra == 'doc' - - sphinx-rtd-theme ; extra == 'doc' - - yarl ; extra == 'doc' - - dropbox ; extra == 'dropbox' - - dropboxdrivefs ; extra == 'dropbox' - - requests ; extra == 'dropbox' - - adlfs ; extra == 'full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full' - - dask ; extra == 'full' - - distributed ; extra == 'full' - - dropbox ; extra == 'full' - - dropboxdrivefs ; extra == 'full' - - fusepy ; extra == 'full' - - gcsfs>2024.2.0 ; extra == 'full' - - libarchive-c ; extra == 'full' - - ocifs ; extra == 'full' - - panel ; extra == 'full' - - paramiko ; extra == 'full' - - pyarrow>=1 ; extra == 'full' - - pygit2 ; extra == 'full' - - requests ; extra == 'full' - - s3fs>2024.2.0 ; extra == 'full' - - smbprotocol ; extra == 'full' - - tqdm ; extra == 'full' - - fusepy ; extra == 'fuse' - - gcsfs>2024.2.0 ; extra == 'gcs' - - pygit2 ; extra == 'git' - - requests ; extra == 'github' - - gcsfs ; extra == 'gs' - - panel ; extra == 'gui' - - pyarrow>=1 ; extra == 'hdfs' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http' - - libarchive-c ; extra == 'libarchive' - - ocifs ; extra == 'oci' - - s3fs>2024.2.0 ; extra == 's3' - - paramiko ; extra == 'sftp' - - smbprotocol ; extra == 'smb' - - paramiko ; extra == 'ssh' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test' - - numpy ; extra == 'test' - - pytest ; extra == 'test' - - pytest-asyncio!=0.22.0 ; extra == 'test' - - pytest-benchmark ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-mock ; extra == 'test' - - pytest-recording ; extra == 'test' - - pytest-rerunfailures ; extra == 'test' - - requests ; extra == 'test' - - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream' - - dask[dataframe,test] ; extra == 'test-downstream' - - moto[server]>4,<5 ; extra == 'test-downstream' - - pytest-timeout ; extra == 'test-downstream' - - xarray ; extra == 'test-downstream' - - adlfs ; extra == 'test-full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full' - - backports-zstd ; python_full_version < '3.14' and extra == 'test-full' - - cloudpickle ; extra == 'test-full' - - dask ; extra == 'test-full' - - distributed ; extra == 'test-full' - - dropbox ; extra == 'test-full' - - dropboxdrivefs ; extra == 'test-full' - - fastparquet ; extra == 'test-full' - - fusepy ; extra == 'test-full' - - gcsfs ; extra == 'test-full' - - jinja2 ; extra == 'test-full' - - kerchunk ; extra == 'test-full' - - libarchive-c ; extra == 'test-full' - - lz4 ; extra == 'test-full' - - notebook ; extra == 'test-full' - - numpy ; extra == 'test-full' - - ocifs ; extra == 'test-full' - - pandas<3.0.0 ; extra == 'test-full' - - panel ; extra == 'test-full' - - paramiko ; extra == 'test-full' - - pyarrow ; extra == 'test-full' - - pyarrow>=1 ; extra == 'test-full' - - pyftpdlib ; extra == 'test-full' - - pygit2 ; extra == 'test-full' - - pytest ; extra == 'test-full' - - pytest-asyncio!=0.22.0 ; extra == 'test-full' - - pytest-benchmark ; extra == 'test-full' - - pytest-cov ; extra == 'test-full' - - pytest-mock ; extra == 'test-full' - - pytest-recording ; extra == 'test-full' - - pytest-rerunfailures ; extra == 'test-full' - - python-snappy ; extra == 'test-full' - - requests ; extra == 'test-full' - - smbprotocol ; extra == 'test-full' - - tqdm ; extra == 'test-full' - - urllib3 ; extra == 'test-full' - - zarr ; extra == 'test-full' - - zstandard ; python_full_version < '3.14' and extra == 'test-full' - - tqdm ; extra == 'tqdm' - 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 @@ -12451,27 +11468,6 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d8/8b/e2bbeb42068f0c48899e8eddd34902afc0f7429d4d2a152d2dc2670dc661/pythreejs-2.4.2-py3-none-any.whl - name: pythreejs - version: 2.4.2 - sha256: 8418807163ad91f4df53b58c4e991b26214852a1236f28f1afeaadf99d095818 - requires_dist: - - ipywidgets>=7.2.1 - - ipydatawidgets>=1.1.1 - - numpy - - traitlets - - sphinx>=1.5 ; extra == 'docs' - - nbsphinx>=0.2.13 ; extra == 'docs' - - nbsphinx-link ; extra == 'docs' - - sphinx-rtd-theme ; extra == 'docs' - - scipy ; extra == 'examples' - - matplotlib ; extra == 'examples' - - scikit-image ; extra == 'examples' - - ipywebrtc ; extra == 'examples' - - nbval ; extra == 'test' - - pytest-check-links ; extra == 'test' - - numpy>=1.14 ; extra == 'test' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl name: pillow version: 12.2.0 @@ -12560,11 +11556,6 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - name: locket - version: 1.0.0 - sha256: b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3 - requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*' - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl name: watchdog version: 6.0.0 @@ -12627,21 +11618,6 @@ packages: requires_dist: - typing-extensions>=4.12.0 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/dc/b7/901d837999a9350a7773289f7760cb473d4ba01fdc6ebae0ff2553d269ac/ncrystal_python-4.4.2-py3-none-any.whl - name: ncrystal-python - version: 4.4.2 - sha256: f419318d088fade6bcff1e39e15baf6fe69fcf5306dd681fca1106d1f63a89ce - requires_dist: - - numpy>=1.22 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl - name: email-validator - version: 2.3.0 - sha256: 80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 - requires_dist: - - dnspython>=2.0.0 - - idna>=2.0.0 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl name: markdown version: 3.10.2 @@ -12674,37 +11650,6 @@ packages: version: 1.5.4 sha256: 7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/e4/bc/daa30c02069eeac5b9198985ba42f5d65ca71bed6705b18329e51d352b7c/plopp-26.4.2-py3-none-any.whl - name: plopp - version: 26.4.2 - sha256: 5cab99bb0905ce08a1d1d7d82f0f64cee7d594269ec1bd01a8a361bd14ab7bff - requires_dist: - - lazy-loader>=0.4 - - matplotlib>=3.8 - - scipp>=25.8.0 ; extra == 'scipp' - - plopp[scipp] ; 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' - - plopp[scipp] ; extra == 'test' - - 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' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/e5/04/c5bb20d64417d20cba0105277235c51969444fa873000fbc26ac0a3fc5a8/gemmi-0.7.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: gemmi version: 0.7.5 @@ -12771,11 +11716,6 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f1/2c/3850985d4c64048dec7b826f8a803e135b52b11b4c81c9cd4326b1ca15ab/ncrystal_core-4.4.2-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: ncrystal-core - version: 4.4.2 - sha256: d0d9c47cd017b7cefc52dde50546d7c151bfdd75d345e42e2b3e74ab5fe83c62 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl name: multidict version: 6.7.1 @@ -12783,21 +11723,6 @@ packages: requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/f1/5b/e63c877c4c94382b66de5045e08ec8cd960e8a4d22f0d62a4dfb1f9e5ac6/ipydatawidgets-4.3.5-py2.py3-none-any.whl - name: ipydatawidgets - version: 4.3.5 - sha256: d590cdb7c364f2f6ab346f20b9d2dd661d27a834ef7845bc9d7113118f05ec87 - requires_dist: - - ipywidgets>=7.0.0 - - numpy - - traittypes>=0.2.0 - - sphinx ; extra == 'docs' - - recommonmark ; extra == 'docs' - - sphinx-rtd-theme ; extra == 'docs' - - pytest>=4 ; extra == 'test' - - pytest-cov ; extra == 'test' - - nbval>=0.9.2 ; extra == 'test' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl name: pathspec version: 1.1.1 @@ -12851,55 +11776,11 @@ packages: version: 0.1.4 sha256: 27b3b67cf898684e646d569f017cb27046774ad23866cb0bdf51d5f76a46476b requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/f5/57/2a154a69d6642860300bf8eb205d13131104991f2b1065bbb9075ac5c32e/tof-26.3.0-py3-none-any.whl - name: tof - version: 26.3.0 - sha256: e89783a072b05fdb53d9e76fbf919dc8935e75e118fdaf17ca5cc33727ef002b - requires_dist: - - plopp>=23.10.0 - - pooch>=1.5.0 - - scipp>=25.1.0 - - lazy-loader>=0.3 - - pytest>=8.0 ; extra == 'test' - - scippneutron>=24.12.0 ; extra == 'test' - requires_python: '>=3.11' - 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/00/bbca25f8a2372465cdf93138c1e1e38dff045fb0afef1488f395d0afcb3b/ncrystal-4.4.2-py3-none-any.whl - name: ncrystal - version: 4.4.2 - sha256: e02fa7d743addc3fbea23287737a88b8d01192450fdca51554d3f9032fe4617c - requires_dist: - - ncrystal-core==4.4.2 - - ncrystal-python==4.4.2 - - spglib>=2.1.0 ; extra == 'composer' - - ase>=3.23.0 ; extra == 'cif' - - gemmi>=0.6.1 ; extra == 'cif' - - spglib>=2.1.0 ; extra == 'cif' - - endf-parserpy>=0.14.3 ; extra == 'endf' - - matplotlib>=3.6.0 ; extra == 'plot' - - ase>=3.23.0 ; extra == 'all' - - endf-parserpy>=0.14.3 ; extra == 'all' - - gemmi>=0.6.1 ; extra == 'all' - - matplotlib>=3.6.0 ; extra == 'all' - - spglib>=2.1.0 ; extra == 'all' - - pyyaml>=6.0.0 ; extra == 'devel' - - ase>=3.23.0 ; extra == 'devel' - - cppcheck ; extra == 'devel' - - endf-parserpy>=0.14.3 ; extra == 'devel' - - gemmi>=0.6.1 ; extra == 'devel' - - matplotlib>=3.6.0 ; extra == 'devel' - - mpmath>=1.3.0 ; extra == 'devel' - - numpy>=1.22 ; extra == 'devel' - - pybind11>=2.11.0 ; extra == 'devel' - - ruff>=0.8.1 ; extra == 'devel' - - simple-build-system>=1.6.0 ; extra == 'devel' - - spglib>=2.1.0 ; extra == 'devel' - - tomli>=2.0.0 ; python_full_version < '3.11' and extra == 'devel' - requires_python: '>=3.8' - 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 @@ -12934,11 +11815,6 @@ packages: - multidict>=4.0 - propcache>=0.2.1 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - name: toolz - version: 1.1.0 - sha256: 15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8 - requires_python: '>=3.9' - 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/pyproject.toml b/pyproject.toml index 507a069d1..d953cfa19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,6 @@ dependencies = [ [project.optional-dependencies] dev = [ - 'essdiffraction', # ESS-specific diffraction library 'GitPython', # Interact with Git repositories 'build', # Building the package 'pre-commit', # Pre-commit hooks diff --git a/src/easydiffraction/analysis/__init__.py b/src/easydiffraction/analysis/__init__.py index 186c375c8..325c9882a 100644 --- a/src/easydiffraction/analysis/__init__.py +++ b/src/easydiffraction/analysis/__init__.py @@ -11,7 +11,7 @@ from easydiffraction.analysis.categories.fit_parameters import FitParameterItem from easydiffraction.analysis.categories.fit_parameters import FitParameters from easydiffraction.analysis.categories.fit_parameters import FitParametersFactory -from easydiffraction.analysis.categories.fit_result import FitResult +from easydiffraction.analysis.categories.fit_result import FitResultBase from easydiffraction.analysis.categories.fit_result import FitResultFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection from easydiffraction.analysis.categories.joint_fit import JointFitFactory diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 87676646e..9a01d8385 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -15,7 +15,6 @@ from easydiffraction.analysis.categories.constraints.factory import ConstraintsFactory from easydiffraction.analysis.categories.fit_parameter_correlations import FitParameterCorrelations from easydiffraction.analysis.categories.fit_parameters import FitParameters -from easydiffraction.analysis.categories.fit_result import FitResult from easydiffraction.analysis.categories.fitting_mode import FittingMode from easydiffraction.analysis.categories.fitting_mode import FittingModeFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection @@ -56,6 +55,7 @@ from easydiffraction.utils.utils import render_table if TYPE_CHECKING: + from easydiffraction.analysis.categories.fit_result import FitResultBase from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase from easydiffraction.core.posterior import PosteriorParameterSummary @@ -429,7 +429,7 @@ def fit_parameters(self) -> FitParameters: return self._fit_parameters @property - def fit_result(self) -> FitResult: + def fit_result(self) -> FitResultBase: """Persisted common fit-result status metadata.""" return self._fit_result @@ -480,7 +480,7 @@ def __init__(self, project: object) -> None: ) self._sequential_fit_extract = SequentialFitExtractCollection() self._fit_parameters = FitParameters() - self._fit_result = FitResult() + self._fit_result = self._minimizer._fit_result_class() self._fit_parameter_correlations = FitParameterCorrelations() self._has_persisted_fit_state_data = False self._persisted_fit_state_sidecar: dict[str, object] = {} @@ -736,13 +736,13 @@ def _restore_fit_results_from_projection(self) -> object | None: starting_parameters=list(restored_parameters), fitting_time=fitting_time, sampler_name=sampler_name, - point_estimate_name=self.minimizer.point_estimate_name.value, + point_estimate_name=self.fit_result.point_estimate_name.value or 'best_sample', posterior_samples=posterior_samples, posterior_parameter_summaries=self._restored_posterior_summaries(), posterior_predictive=self._restored_predictive_summaries(), credible_interval_levels=( - float(self.minimizer.credible_interval_inner.value), - float(self.minimizer.credible_interval_outer.value), + float(self.fit_result.credible_interval_inner.value), + float(self.fit_result.credible_interval_outer.value), ), sampler_settings={ 'steps': int(sampler_settings.get('steps', 0)), @@ -755,17 +755,17 @@ def _restore_fit_results_from_projection(self) -> object | None: }, convergence_diagnostics={ 'converged': False, - 'max_r_hat': self.minimizer.gelman_rubin_max.value, - 'min_ess_bulk': self.minimizer.effective_sample_size_min.value, + 'max_r_hat': self.fit_result.gelman_rubin_max.value, + 'min_ess_bulk': self.fit_result.effective_sample_size_min.value, 'n_draws': int(sample_shape[0]), 'n_chains': int(sample_shape[1]), 'n_parameters': int(sample_shape[2]), }, - sampler_completed=bool(self.minimizer.sampler_completed.value), - best_log_posterior=self.minimizer.best_log_posterior.value, + sampler_completed=bool(self.fit_result.sampler_completed.value), + best_log_posterior=self.fit_result.best_log_posterior.value, ) - restored_results.message = self.fit_result.message.value - restored_results.iterations = int(self.fit_result.iterations.value) + restored_results.message = self.fit_result.message.value or '' + restored_results.iterations = _int_or_none(self.fit_result.iterations.value) or 0 self.fit_results = restored_results return restored_results @@ -778,21 +778,21 @@ def _restore_fit_results_from_projection(self) -> object | None: fitting_time=fitting_time, optimizer_name=engine_metadata['optimizer_name'], method_name=engine_metadata['method_name'], - objective_name=self.minimizer.objective_name.value, - objective_value=self.minimizer.objective_value.value, - n_data_points=_int_or_none(self.minimizer.n_data_points.value), - n_parameters=_int_or_none(self.minimizer.n_parameters.value), - n_free_parameters=_int_or_none(self.minimizer.n_free_parameters.value), - degrees_of_freedom=_int_or_none(self.minimizer.degrees_of_freedom.value), - covariance_available=self.minimizer.covariance_available.value, - correlation_available=self.minimizer.correlation_available.value, - runtime_seconds=self.minimizer.runtime_seconds.value, - iterations_performed=_int_or_none(self.minimizer.iterations_performed.value), - exit_reason=self.minimizer.exit_reason.value, + objective_name=self.fit_result.objective_name.value, + objective_value=self.fit_result.objective_value.value, + n_data_points=_int_or_none(self.fit_result.n_data_points.value), + n_parameters=_int_or_none(self.fit_result.n_parameters.value), + n_free_parameters=_int_or_none(self.fit_result.n_free_parameters.value), + degrees_of_freedom=_int_or_none(self.fit_result.degrees_of_freedom.value), + covariance_available=self.fit_result.covariance_available.value, + correlation_available=self.fit_result.correlation_available.value, + runtime_seconds=fitting_time, + iterations_performed=_int_or_none(self.fit_result.iterations.value), + exit_reason=self.fit_result.exit_reason.value, ) - restored_results.message = self.fit_result.message.value - restored_results.iterations = int(self.fit_result.iterations.value) - restored_results.chi_square = self.minimizer.objective_value.value + restored_results.message = self.fit_result.message.value or '' + restored_results.iterations = _int_or_none(self.fit_result.iterations.value) or 0 + restored_results.chi_square = self.fit_result.objective_value.value self.fit_results = restored_results return restored_results @@ -1062,8 +1062,11 @@ def _replace_minimizer( self._warn_about_minimizer_swap_defaults(old_defaults, new_minimizer) old_minimizer._parent = None + self._fit_result._parent = None self._minimizer = new_minimizer + self._fit_result = new_minimizer._fit_result_class() self._minimizer._parent = self + self._fit_result._parent = self self._fitter = Fitter(value) if announce: console.paragraph('Current minimizer changed to') @@ -1203,16 +1206,19 @@ def _fit_state_categories(self) -> list[object]: def _clear_persisted_fit_state(self) -> None: """Reset all persisted fit-state categories before a new fit.""" - self._clear_minimizer_result_projection() self._fit_parameters = FitParameters() - self._fit_result = FitResult() + self._fit_result._parent = None + self._fit_result = self.minimizer._fit_result_class() + self._fit_result._parent = self self._fit_parameter_correlations = FitParameterCorrelations() self._set_has_persisted_fit_state(value=False) self._persisted_fit_state_sidecar = {} - def _clear_minimizer_result_projection(self) -> None: - """Reset result-only fields on the active minimizer category.""" - self.minimizer._reset_result_descriptors() + def _clear_fit_result_projection(self) -> None: + """ + Reset result-only fields on the active fit-result category. + """ + self.fit_result._reset_result_descriptors() def _capture_fit_parameter_state(self, parameters: list[Parameter]) -> None: """Capture pre-fit parameter state.""" @@ -1372,17 +1378,15 @@ def _store_least_squares_result_projection( else None ) - self.minimizer._set_objective_name('chi_square') - self.minimizer._set_objective_value(self._resolve_objective_value(results)) - self.minimizer._set_n_data_points(n_data_points) - self.minimizer._set_n_parameters(n_parameters) - self.minimizer._set_n_free_parameters(n_free_parameters) - self.minimizer._set_degrees_of_freedom(degrees_of_freedom) - self.minimizer._set_covariance_available(value=covariance is not None) - self.minimizer._set_correlation_available(value=correlation_matrix is not None) - self.minimizer._set_runtime_seconds(results.fitting_time) - self.minimizer._set_iterations_performed(results.iterations) - self.minimizer._set_exit_reason(results.message) + self.fit_result._set_objective_name('chi_square') + self.fit_result._set_objective_value(self._resolve_objective_value(results)) + self.fit_result._set_n_data_points(n_data_points) + self.fit_result._set_n_parameters(n_parameters) + self.fit_result._set_n_free_parameters(n_free_parameters) + self.fit_result._set_degrees_of_freedom(degrees_of_freedom) + self.fit_result._set_covariance_available(value=covariance is not None) + self.fit_result._set_correlation_available(value=correlation_matrix is not None) + self.fit_result._set_exit_reason(results.message) if correlation_matrix is not None: self._store_correlation_projection( @@ -1679,15 +1683,14 @@ def _store_posterior_fit_projection(self, results: BayesianFitResults) -> None: point_estimate_name = results.point_estimate_name or 'best_sample' convergence = results.convergence_diagnostics - self.minimizer._set_runtime_seconds(results.fitting_time) - self.minimizer._set_point_estimate_name(point_estimate_name) - self.minimizer._set_sampler_completed(value=results.sampler_completed) - self.minimizer._set_best_log_posterior(results.best_log_posterior) - self.minimizer._set_credible_interval_inner(credible_interval_inner) - self.minimizer._set_credible_interval_outer(credible_interval_outer) - self.minimizer._set_gelman_rubin_max(convergence.get('max_r_hat')) - self.minimizer._set_effective_sample_size_min(convergence.get('min_ess_bulk')) - self.minimizer._set_acceptance_rate_mean(convergence.get('acceptance_rate_mean')) + self.fit_result._set_point_estimate_name(point_estimate_name) + self.fit_result._set_sampler_completed(value=results.sampler_completed) + self.fit_result._set_best_log_posterior(results.best_log_posterior) + self.fit_result._set_credible_interval_inner(credible_interval_inner) + self.fit_result._set_credible_interval_outer(credible_interval_outer) + self.fit_result._set_gelman_rubin_max(convergence.get('max_r_hat')) + self.fit_result._set_effective_sample_size_min(convergence.get('min_ess_bulk')) + self.fit_result._set_acceptance_rate_mean(convergence.get('acceptance_rate_mean')) self._store_posterior_samples_sidecar_projection(results) live_parameters = { @@ -1945,33 +1948,14 @@ def _fit_single( self.fitter.minimizer.tracker._set_shared_display_handle(short_display_handle) try: - for expt_name in expt_names: - if verb is VerbosityEnum.FULL: - console.print( - f"📋 Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting" - ) - - experiment = experiments[expt_name] - self.fitter.fit( - structures, - [experiment], - analysis=self, - verbosity=verb, - use_physical_limits=use_physical_limits, - random_seed=self._resolved_fit_random_seed(random_seed), - ) - - # After fitting, snapshot parameter values before - # they get overwritten by the next experiment's fit - results = self.fitter.results - self._snapshot_params(expt_name, results) - self.fit_results = results - - # Short mode: append one summary row and update in-place - if verb is VerbosityEnum.SHORT: - self._fit_single_update_short_table( - short_rows, expt_name, results, short_display_handle - ) + self._fit_single_experiments( + verb, + structures, + experiments, + use_physical_limits=use_physical_limits, + random_seed=random_seed, + short_state=(short_rows, short_display_handle), + ) finally: self.fitter.minimizer.tracker._set_shared_display_handle(None) @@ -1980,6 +1964,47 @@ def _fit_single( with suppress(Exception): short_display_handle.close() + def _fit_single_experiments( + self, + verb: VerbosityEnum, + structures: object, + experiments: object, + *, + use_physical_limits: bool, + random_seed: int | None, + short_state: tuple[list[list[str]], object], + ) -> None: + """Run the per-experiment loop for single-fit mode.""" + short_rows, short_display_handle = short_state + for expt_name in experiments.names: + if verb is VerbosityEnum.FULL: + console.print( + f"📋 Using experiment 🔬 '{expt_name}' for " + f"'{FitModeEnum.SINGLE.value}' fitting" + ) + + experiment = experiments[expt_name] + self.fitter.fit( + structures, + [experiment], + analysis=self, + verbosity=verb, + use_physical_limits=use_physical_limits, + random_seed=self._resolved_fit_random_seed(random_seed), + ) + + results = self.fitter.results + self._snapshot_params(expt_name, results) + self.fit_results = results + + if verb is VerbosityEnum.SHORT: + self._fit_single_update_short_table( + short_rows, + expt_name, + results, + short_display_handle, + ) + @staticmethod def _fit_single_print_header( verb: VerbosityEnum, diff --git a/src/easydiffraction/analysis/calculators/crysfml.py b/src/easydiffraction/analysis/calculators/crysfml.py index 85ff5779f..70231b02f 100644 --- a/src/easydiffraction/analysis/calculators/crysfml.py +++ b/src/easydiffraction/analysis/calculators/crysfml.py @@ -129,22 +129,40 @@ def calculate_pattern( crysfml_dict = self._crysfml_dict(structure, experiment) try: - if experiment.type.beam_mode.value == BeamModeEnum.CONSTANT_WAVELENGTH: - _, y = cfml_py_utilities.cw_powder_pattern_from_dict(crysfml_dict) - elif experiment.type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT: - _, y = cfml_py_utilities.tof_powder_pattern_from_dict(crysfml_dict) - else: - print( - f'[CrysfmlCalculator] Error: ' - f'Unsupported beam mode {experiment.type.beam_mode.value}' - ) - return np.array([]) - y = self._adjust_pattern_length(y, len(experiment.data.x)) + y = self._calculate_adjusted_pattern(crysfml_dict, experiment) except KeyError: print('[CrysfmlCalculator] Error: No calculated data') y = [] return np.asarray(y) + def _calculate_adjusted_pattern( + self, + crysfml_dict: dict[str, object], + experiment: ExperimentBase, + ) -> list[float]: + """Calculate a Crysfml pattern and match experiment length.""" + y = self._calculate_raw_pattern(crysfml_dict, experiment) + if y is None: + return [] + return self._adjust_pattern_length(y, len(experiment.data.x)) + + @staticmethod + def _calculate_raw_pattern( + crysfml_dict: dict[str, object], + experiment: ExperimentBase, + ) -> list[float] | None: + """Calculate a Crysfml pattern without length adjustment.""" + if experiment.type.beam_mode.value == BeamModeEnum.CONSTANT_WAVELENGTH: + _, y = cfml_py_utilities.cw_powder_pattern_from_dict(crysfml_dict) + return y + if experiment.type.beam_mode.value == BeamModeEnum.TIME_OF_FLIGHT: + _, y = cfml_py_utilities.tof_powder_pattern_from_dict(crysfml_dict) + return y + print( + f'[CrysfmlCalculator] Error: Unsupported beam mode {experiment.type.beam_mode.value}' + ) + return None + def _adjust_pattern_length( # noqa: PLR6301 self, pattern: list[float], diff --git a/src/easydiffraction/analysis/calculators/pdffit.py b/src/easydiffraction/analysis/calculators/pdffit.py index ed0717536..a9c3b3697 100644 --- a/src/easydiffraction/analysis/calculators/pdffit.py +++ b/src/easydiffraction/analysis/calculators/pdffit.py @@ -19,17 +19,19 @@ from easydiffraction.datablocks.experiment.item.base import ExperimentBase from easydiffraction.datablocks.structure.item.base import Structure + +def _open_pdffit_devnull() -> object: + """Open a durable devnull handle for PDFfit stdout redirection.""" + with Path(os.devnull).open('w', encoding='utf-8') as tmp_devnull: + return os.fdopen(os.dup(tmp_devnull.fileno()), 'w') + + try: from diffpy.pdffit2 import PdfFit from diffpy.pdffit2 import redirect_stdout from diffpy.structure.parsers.p_cif import P_cif as pdffit_cif_parser - # Silence the C++ engine output while keeping the handle open - _pdffit_devnull: object | None - with Path(os.devnull).open('w', encoding='utf-8') as _tmp_devnull: - # Duplicate file descriptor so the handle remains - # valid after the context - _pdffit_devnull = os.fdopen(os.dup(_tmp_devnull.fileno()), 'w') + _pdffit_devnull = _open_pdffit_devnull() redirect_stdout(_pdffit_devnull) # TODO: Add the following print to debug mode # print("✅ 'pdffit' calculation engine is successfully imported.") diff --git a/src/easydiffraction/analysis/categories/__init__.py b/src/easydiffraction/analysis/categories/__init__.py index d6dcda46b..54fea54e5 100644 --- a/src/easydiffraction/analysis/categories/__init__.py +++ b/src/easydiffraction/analysis/categories/__init__.py @@ -11,7 +11,7 @@ from easydiffraction.analysis.categories.fit_parameter_correlations import FitParameterCorrelations from easydiffraction.analysis.categories.fit_parameters import FitParameterItem from easydiffraction.analysis.categories.fit_parameters import FitParameters -from easydiffraction.analysis.categories.fit_result import FitResult +from easydiffraction.analysis.categories.fit_result import FitResultBase from easydiffraction.analysis.categories.fitting_mode import FittingMode from easydiffraction.analysis.categories.fitting_mode import FittingModeFactory from easydiffraction.analysis.categories.joint_fit import JointFitCollection diff --git a/src/easydiffraction/analysis/categories/fit_result/__init__.py b/src/easydiffraction/analysis/categories/fit_result/__init__.py index 8578ab47b..6bc137ee8 100644 --- a/src/easydiffraction/analysis/categories/fit_result/__init__.py +++ b/src/easydiffraction/analysis/categories/fit_result/__init__.py @@ -1,5 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from easydiffraction.analysis.categories.fit_result.default import FitResult +from easydiffraction.analysis.categories.fit_result.base import FitResultBase +from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory +from easydiffraction.analysis.categories.fit_result.lsq import LeastSquaresFitResult diff --git a/src/easydiffraction/analysis/categories/fit_result/base.py b/src/easydiffraction/analysis/categories/fit_result/base.py new file mode 100644 index 000000000..c94a35b09 --- /dev/null +++ b/src/easydiffraction/analysis/categories/fit_result/base.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Common fit-result status category.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory +from easydiffraction.analysis.enums import FitResultKindEnum +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import GenericDescriptorBase +from easydiffraction.core.variable import IntegerDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +@FitResultFactory.register +class FitResultBase(CategoryItem): + """Common persisted fit-result status metadata.""" + + _category_code = 'fit_result' + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'success', + 'message', + 'iterations', + 'fitting_time', + 'reduced_chi_square', + 'result_kind', + ) + + type_info = TypeInfo( + tag='default', + description='Common persisted fit-result status metadata', + ) + + def __init__(self) -> None: + super().__init__() + self._result_kind = StringDescriptor( + name='result_kind', + description='Kind of the latest persisted fit-result projection.', + value_spec=AttributeSpec( + default=FitResultKindEnum.default().value, + validator=MembershipValidator( + allowed=[member.value for member in FitResultKindEnum] + ), + ), + cif_handler=CifHandler(names=['_fit_result.result_kind']), + ) + self._success = BoolDescriptor( + name='success', + description='Whether the latest persisted fit-result projection succeeded.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.success']), + ) + self._message = StringDescriptor( + name='message', + description='Status message for the latest persisted fit-result projection.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.message']), + ) + self._iterations = IntegerDescriptor( + name='iterations', + description='Iteration count for the latest persisted fit-result projection.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.iterations']), + ) + self._fitting_time = NumericDescriptor( + name='fitting_time', + description='Fitting time in seconds for the latest persisted projection.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.fitting_time']), + ) + self._reduced_chi_square = NumericDescriptor( + name='reduced_chi_square', + description='Reduced chi-square for the latest persisted projection.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.reduced_chi_square']), + ) + + @property + def result_kind(self) -> StringDescriptor: + """Kind of the latest persisted fit-result projection.""" + return self._result_kind + + def _set_result_kind(self, value: str) -> None: + """Set the result kind for internal callers.""" + self._result_kind.value = value + + @property + def success(self) -> BoolDescriptor: + """ + Whether the latest persisted fit-result projection succeeded. + """ + return self._success + + def _set_success(self, *, value: bool | None) -> None: + """Set the success flag for internal callers.""" + self._success.value = value + + @property + def message(self) -> StringDescriptor: + """ + Status message for the latest persisted fit-result projection. + """ + return self._message + + def _set_message(self, value: str | None) -> None: + """Set the fit-result message for internal callers.""" + self._message.value = value + + @property + def iterations(self) -> IntegerDescriptor: + """ + Iteration count for the latest persisted fit-result projection. + """ + return self._iterations + + def _set_iterations(self, value: int | None) -> None: + """Set the iteration count for internal callers.""" + self._iterations.value = value + + @property + def fitting_time(self) -> NumericDescriptor: + """ + Fitting time in seconds for the latest persisted projection. + """ + return self._fitting_time + + def _set_fitting_time(self, value: float | None) -> None: + """Set the fitting time for internal callers.""" + self._fitting_time.value = value + + @property + def reduced_chi_square(self) -> NumericDescriptor: + """Reduced chi-square for the latest persisted projection.""" + return self._reduced_chi_square + + def _set_reduced_chi_square(self, value: float | None) -> None: + """Set the reduced chi-square for internal callers.""" + self._reduced_chi_square.value = value + + def _reset_result_descriptors(self) -> None: + """Reset fit-result descriptors to declared defaults.""" + for name in self._result_descriptor_names: + descriptor = getattr(self, name) + if isinstance(descriptor, GenericDescriptorBase): + descriptor.value = descriptor._value_spec.default_value() diff --git a/src/easydiffraction/analysis/categories/fit_result/bayesian.py b/src/easydiffraction/analysis/categories/fit_result/bayesian.py new file mode 100644 index 000000000..91e1310f1 --- /dev/null +++ b/src/easydiffraction/analysis/categories/fit_result/bayesian.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Bayesian fit-result category.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.fit_result.base import FitResultBase +from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +@FitResultFactory.register +class BayesianFitResult(FitResultBase): + """Persisted Bayesian fit-result metadata.""" + + type_info = TypeInfo( + tag='bayesian', + description='Persisted Bayesian fit-result metadata', + ) + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + *FitResultBase._result_descriptor_names, + 'point_estimate_name', + 'sampler_completed', + 'credible_interval_inner', + 'credible_interval_outer', + 'acceptance_rate_mean', + 'gelman_rubin_max', + 'effective_sample_size_min', + 'best_log_posterior', + ) + _expected_descriptor_names: ClassVar[tuple[str, ...]] = _result_descriptor_names + + def __init__(self) -> None: + super().__init__() + self._point_estimate_name = self._point_estimate_name_descriptor() + self._sampler_completed = self._sampler_completed_descriptor() + self._credible_interval_inner = self._credible_interval_inner_descriptor() + self._credible_interval_outer = self._credible_interval_outer_descriptor() + self._acceptance_rate_mean = self._acceptance_rate_mean_descriptor() + self._gelman_rubin_max = self._gelman_rubin_max_descriptor() + self._effective_sample_size_min = self._effective_sample_size_min_descriptor() + self._best_log_posterior = self._best_log_posterior_descriptor() + + @staticmethod + def _point_estimate_name_descriptor() -> StringDescriptor: + """Create a point-estimate-name descriptor.""" + return StringDescriptor( + name='point_estimate_name', + description='Committed sampled point estimate name.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.point_estimate_name']), + ) + + @staticmethod + def _sampler_completed_descriptor() -> BoolDescriptor: + """Create a sampler-completed descriptor.""" + return BoolDescriptor( + name='sampler_completed', + description='Whether the sampler completed and returned posterior data.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.sampler_completed']), + ) + + @staticmethod + def _credible_interval_inner_descriptor() -> NumericDescriptor: + """Create an inner credible-interval descriptor.""" + return NumericDescriptor( + name='credible_interval_inner', + description='Inner credible-interval level used in summaries.', + value_spec=AttributeSpec(default=0.68), + cif_handler=CifHandler(names=['_fit_result.credible_interval_inner']), + ) + + @staticmethod + def _credible_interval_outer_descriptor() -> NumericDescriptor: + """Create an outer credible-interval descriptor.""" + return NumericDescriptor( + name='credible_interval_outer', + description='Outer credible-interval level used in summaries.', + value_spec=AttributeSpec(default=0.95), + cif_handler=CifHandler(names=['_fit_result.credible_interval_outer']), + ) + + @staticmethod + def _acceptance_rate_mean_descriptor() -> NumericDescriptor: + """Create an acceptance-rate descriptor.""" + return NumericDescriptor( + name='acceptance_rate_mean', + description='Mean sampler acceptance rate.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.acceptance_rate_mean']), + ) + + @staticmethod + def _gelman_rubin_max_descriptor() -> NumericDescriptor: + """Create a Gelman-Rubin descriptor.""" + return NumericDescriptor( + name='gelman_rubin_max', + description='Maximum rank-normalized split R-hat.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.gelman_rubin_max']), + ) + + @staticmethod + def _effective_sample_size_min_descriptor() -> NumericDescriptor: + """Create an effective-sample-size descriptor.""" + return NumericDescriptor( + name='effective_sample_size_min', + description='Minimum bulk effective sample size.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.effective_sample_size_min']), + ) + + @staticmethod + def _best_log_posterior_descriptor() -> NumericDescriptor: + """Create a best-log-posterior descriptor.""" + return NumericDescriptor( + name='best_log_posterior', + description='Best log-posterior value found.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.best_log_posterior']), + ) + + @property + def point_estimate_name(self) -> StringDescriptor: + """Committed sampled point estimate name.""" + return self._point_estimate_name + + def _set_point_estimate_name(self, value: str | None) -> None: + """Set the point-estimate name for internal callers.""" + self._point_estimate_name.value = value + + @property + def sampler_completed(self) -> BoolDescriptor: + """Whether the sampler completed and returned posterior data.""" + return self._sampler_completed + + def _set_sampler_completed(self, *, value: bool | None) -> None: + """Set the sampler-completed flag for internal callers.""" + self._sampler_completed.value = value + + @property + def credible_interval_inner(self) -> NumericDescriptor: + """Inner credible-interval level used in summaries.""" + return self._credible_interval_inner + + def _set_credible_interval_inner(self, value: float) -> None: + """ + Set the inner credible-interval level for internal callers. + """ + self._credible_interval_inner.value = value + + @property + def credible_interval_outer(self) -> NumericDescriptor: + """Outer credible-interval level used in summaries.""" + return self._credible_interval_outer + + def _set_credible_interval_outer(self, value: float) -> None: + """ + Set the outer credible-interval level for internal callers. + """ + self._credible_interval_outer.value = value + + @property + def acceptance_rate_mean(self) -> NumericDescriptor: + """Mean sampler acceptance rate.""" + return self._acceptance_rate_mean + + def _set_acceptance_rate_mean(self, value: float | None) -> None: + """Set the acceptance-rate mean for internal callers.""" + self._acceptance_rate_mean.value = value + + @property + def gelman_rubin_max(self) -> NumericDescriptor: + """Maximum rank-normalized split R-hat.""" + return self._gelman_rubin_max + + def _set_gelman_rubin_max(self, value: float | None) -> None: + """Set the maximum R-hat for internal callers.""" + self._gelman_rubin_max.value = value + + @property + def effective_sample_size_min(self) -> NumericDescriptor: + """Minimum bulk effective sample size.""" + return self._effective_sample_size_min + + def _set_effective_sample_size_min(self, value: float | None) -> None: + """ + Set the minimum effective sample size for internal callers. + """ + self._effective_sample_size_min.value = value + + @property + def best_log_posterior(self) -> NumericDescriptor: + """Best log-posterior value found.""" + return self._best_log_posterior + + def _set_best_log_posterior(self, value: float | None) -> None: + """Set the best log-posterior for internal callers.""" + self._best_log_posterior.value = value diff --git a/src/easydiffraction/analysis/categories/fit_result/default.py b/src/easydiffraction/analysis/categories/fit_result/default.py index 564df008e..38c500e1e 100644 --- a/src/easydiffraction/analysis/categories/fit_result/default.py +++ b/src/easydiffraction/analysis/categories/fit_result/default.py @@ -1,135 +1,9 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Common fit-result status category.""" +"""Default fit-result category import.""" from __future__ import annotations -from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory -from easydiffraction.analysis.enums import FitResultKindEnum -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import MembershipValidator -from easydiffraction.core.variable import BoolDescriptor -from easydiffraction.core.variable import IntegerDescriptor -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.analysis.categories.fit_result.base import FitResultBase - -@FitResultFactory.register -class FitResult(CategoryItem): - """Common persisted fit-result status metadata.""" - - _category_code = 'fit_result' - - type_info = TypeInfo( - tag='default', - description='Common persisted fit-result status metadata', - ) - - def __init__(self) -> None: - super().__init__() - self._result_kind = StringDescriptor( - name='result_kind', - description='Kind of the latest persisted fit-result projection.', - value_spec=AttributeSpec( - default=FitResultKindEnum.default().value, - validator=MembershipValidator( - allowed=[member.value for member in FitResultKindEnum] - ), - ), - cif_handler=CifHandler(names=['_fit_result.result_kind']), - ) - self._success = BoolDescriptor( - name='success', - description='Whether the latest persisted fit-result projection succeeded.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_fit_result.success']), - ) - self._message = StringDescriptor( - name='message', - description='Status message for the latest persisted fit-result projection.', - value_spec=AttributeSpec(default=''), - cif_handler=CifHandler(names=['_fit_result.message']), - ) - self._iterations = IntegerDescriptor( - name='iterations', - description='Iteration count for the latest persisted fit-result projection.', - value_spec=AttributeSpec(default=0), - cif_handler=CifHandler(names=['_fit_result.iterations']), - ) - self._fitting_time = NumericDescriptor( - name='fitting_time', - description='Fitting time in seconds for the latest persisted projection.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_fit_result.fitting_time']), - ) - self._reduced_chi_square = NumericDescriptor( - name='reduced_chi_square', - description='Reduced chi-square for the latest persisted projection.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_fit_result.reduced_chi_square']), - ) - - @property - def result_kind(self) -> StringDescriptor: - """Kind of the latest persisted fit-result projection.""" - return self._result_kind - - def _set_result_kind(self, value: str) -> None: - """Set the result kind for internal callers.""" - self._result_kind.value = value - - @property - def success(self) -> BoolDescriptor: - """ - Whether the latest persisted fit-result projection succeeded. - """ - return self._success - - def _set_success(self, *, value: bool) -> None: - """Set the success flag for internal callers.""" - self._success.value = value - - @property - def message(self) -> StringDescriptor: - """ - Status message for the latest persisted fit-result projection. - """ - return self._message - - def _set_message(self, value: str) -> None: - """Set the fit-result message for internal callers.""" - self._message.value = value - - @property - def iterations(self) -> IntegerDescriptor: - """ - Iteration count for the latest persisted fit-result projection. - """ - return self._iterations - - def _set_iterations(self, value: int) -> None: - """Set the iteration count for internal callers.""" - self._iterations.value = value - - @property - def fitting_time(self) -> NumericDescriptor: - """ - Fitting time in seconds for the latest persisted projection. - """ - return self._fitting_time - - def _set_fitting_time(self, value: float | None) -> None: - """Set the fitting time for internal callers.""" - self._fitting_time.value = value - - @property - def reduced_chi_square(self) -> NumericDescriptor: - """Reduced chi-square for the latest persisted projection.""" - return self._reduced_chi_square - - def _set_reduced_chi_square(self, value: float | None) -> None: - """Set the reduced chi-square for internal callers.""" - self._reduced_chi_square.value = value +DEFAULT_FIT_RESULT_CLASS: type[FitResultBase] = FitResultBase diff --git a/src/easydiffraction/analysis/categories/fit_result/lsq.py b/src/easydiffraction/analysis/categories/fit_result/lsq.py new file mode 100644 index 000000000..9e8e31ec9 --- /dev/null +++ b/src/easydiffraction/analysis/categories/fit_result/lsq.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Least-squares fit-result category.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.fit_result.base import FitResultBase +from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +@FitResultFactory.register +class LeastSquaresFitResult(FitResultBase): + """Persisted least-squares fit-result metadata.""" + + type_info = TypeInfo( + tag='least_squares', + description='Persisted least-squares fit-result metadata', + ) + _result_descriptor_names: ClassVar[tuple[str, ...]] = ( + *FitResultBase._result_descriptor_names, + 'objective_name', + 'objective_value', + 'n_data_points', + 'n_parameters', + 'n_free_parameters', + 'degrees_of_freedom', + 'covariance_available', + 'correlation_available', + 'exit_reason', + ) + _expected_descriptor_names: ClassVar[tuple[str, ...]] = _result_descriptor_names + + def __init__(self) -> None: + super().__init__() + self._objective_name = self._string_result_descriptor( + 'objective_name', + 'Objective function name for the persisted deterministic fit.', + ) + self._objective_value = self._numeric_result_descriptor( + 'objective_value', + 'Objective value for the persisted deterministic fit.', + ) + self._n_data_points = self._integer_result_descriptor( + 'n_data_points', + 'Number of data points used in the persisted deterministic fit.', + ) + self._n_parameters = self._integer_result_descriptor( + 'n_parameters', + 'Number of parameters considered in the persisted deterministic fit.', + ) + self._n_free_parameters = self._integer_result_descriptor( + 'n_free_parameters', + 'Number of free parameters in the persisted deterministic fit.', + ) + self._degrees_of_freedom = self._integer_result_descriptor( + 'degrees_of_freedom', + 'Degrees of freedom for the persisted deterministic fit.', + ) + self._covariance_available = self._bool_result_descriptor( + 'covariance_available', + 'Whether covariance was available for the persisted deterministic fit.', + ) + self._correlation_available = self._bool_result_descriptor( + 'correlation_available', + 'Whether correlations were available for the persisted deterministic fit.', + ) + self._exit_reason = self._string_result_descriptor( + 'exit_reason', + 'Backend exit reason for the persisted deterministic fit.', + ) + + @staticmethod + def _string_result_descriptor(name: str, description: str) -> StringDescriptor: + """ + Create a string-valued result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than an empty string, matching the shared "no fit" + semantics of numeric, integer-like, and bool result fields. + """ + return StringDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_fit_result.{name}']), + ) + + @staticmethod + def _numeric_result_descriptor( + name: str, + description: str, + *, + default: float | None = None, + allow_none: bool = True, + ) -> NumericDescriptor: + """Create a numeric result descriptor.""" + return NumericDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=default, allow_none=allow_none), + cif_handler=CifHandler(names=[f'_fit_result.{name}']), + ) + + @staticmethod + def _integer_result_descriptor(name: str, description: str) -> NumericDescriptor: + """ + Create an integer-like numeric result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than ``0``; the scientist audience reads ``0`` as a + degenerate result, not as "no fit yet". + """ + return NumericDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_fit_result.{name}']), + ) + + @staticmethod + def _bool_result_descriptor(name: str, description: str) -> BoolDescriptor: + """ + Create a boolean result descriptor. + + Defaults to ``None`` so a CIF written before any fit emits ``?`` + rather than ``false``; ``false`` would otherwise read as an + active result instead of "no fit happened yet". + """ + return BoolDescriptor( + name=name, + description=description, + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=[f'_fit_result.{name}']), + ) + + @property + def objective_name(self) -> StringDescriptor: + """ + Objective function name for the persisted deterministic fit. + """ + return self._objective_name + + def _set_objective_name(self, value: str | None) -> None: + self._objective_name.value = value + + @property + def objective_value(self) -> NumericDescriptor: + """Objective value for the persisted deterministic fit.""" + return self._objective_value + + def _set_objective_value(self, value: float | None) -> None: + self._objective_value.value = value + + @property + def n_data_points(self) -> NumericDescriptor: + """ + Number of data points used in the persisted deterministic fit. + """ + return self._n_data_points + + def _set_n_data_points(self, value: float | None) -> None: + self._n_data_points.value = value + + @property + def n_parameters(self) -> NumericDescriptor: + """Number of parameters in the persisted deterministic fit.""" + return self._n_parameters + + def _set_n_parameters(self, value: float | None) -> None: + self._n_parameters.value = value + + @property + def n_free_parameters(self) -> NumericDescriptor: + """ + Number of free parameters in the persisted deterministic fit. + """ + return self._n_free_parameters + + def _set_n_free_parameters(self, value: float | None) -> None: + self._n_free_parameters.value = value + + @property + def degrees_of_freedom(self) -> NumericDescriptor: + """Degrees of freedom for the persisted deterministic fit.""" + return self._degrees_of_freedom + + def _set_degrees_of_freedom(self, value: float | None) -> None: + self._degrees_of_freedom.value = value + + @property + def covariance_available(self) -> BoolDescriptor: + """Whether deterministic covariance was available.""" + return self._covariance_available + + def _set_covariance_available(self, *, value: bool | None) -> None: + self._covariance_available.value = value + + @property + def correlation_available(self) -> BoolDescriptor: + """Whether deterministic correlations were available.""" + return self._correlation_available + + def _set_correlation_available(self, *, value: bool | None) -> None: + self._correlation_available.value = value + + @property + def exit_reason(self) -> StringDescriptor: + """Backend exit reason for the persisted deterministic fit.""" + return self._exit_reason + + def _set_exit_reason(self, value: str | None) -> None: + self._exit_reason.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/base.py b/src/easydiffraction/analysis/categories/minimizer/base.py index 1e0a6c672..fb583964d 100644 --- a/src/easydiffraction/analysis/categories/minimizer/base.py +++ b/src/easydiffraction/analysis/categories/minimizer/base.py @@ -6,6 +6,7 @@ from typing import ClassVar +from easydiffraction.analysis.categories.fit_result.base import FitResultBase from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum from easydiffraction.core.category import CategoryItem from easydiffraction.core.switchable import SwitchableCategoryBase @@ -25,6 +26,7 @@ class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): _native_key_map: ClassVar[dict[str, str]] = {} _setting_descriptor_names: ClassVar[tuple[str, ...]] = () _result_descriptor_names: ClassVar[tuple[str, ...]] = () + _fit_result_class: ClassVar[type[FitResultBase]] = FitResultBase def __init__(self) -> None: super().__init__() diff --git a/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py index 7e999b859..30ceafa50 100644 --- a/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py +++ b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py @@ -6,14 +6,13 @@ from typing import ClassVar +from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase from easydiffraction.analysis.minimizers.enums import InitializationMethodEnum from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.validation import MembershipValidator from easydiffraction.core.validation import RangeValidator -from easydiffraction.core.variable import BoolDescriptor from easydiffraction.core.variable import IntegerDescriptor -from easydiffraction.core.variable import NumericDescriptor from easydiffraction.core.variable import StringDescriptor from easydiffraction.io.cif.handler import CifHandler @@ -21,6 +20,7 @@ class BayesianMinimizerBase(MinimizerCategoryBase): """Shared behavior for Bayesian minimizer categories.""" + _fit_result_class: ClassVar[type] = BayesianFitResult _expected_descriptor_names: ClassVar[tuple[str, ...]] = ( 'sampling_steps', 'burn_in_steps', @@ -29,15 +29,6 @@ class BayesianMinimizerBase(MinimizerCategoryBase): 'parallel_workers', 'initialization_method', 'random_seed', - 'runtime_seconds', - 'point_estimate_name', - 'sampler_completed', - 'credible_interval_inner', - 'credible_interval_outer', - 'acceptance_rate_mean', - 'gelman_rubin_max', - 'effective_sample_size_min', - 'best_log_posterior', ) _native_key_map: ClassVar[dict[str, str]] = { 'sampling_steps': 'steps', @@ -57,17 +48,7 @@ class BayesianMinimizerBase(MinimizerCategoryBase): 'initialization_method', 'random_seed', ) - _result_descriptor_names: ClassVar[tuple[str, ...]] = ( - 'runtime_seconds', - 'point_estimate_name', - 'sampler_completed', - 'credible_interval_inner', - 'credible_interval_outer', - 'acceptance_rate_mean', - 'gelman_rubin_max', - 'effective_sample_size_min', - 'best_log_posterior', - ) + _result_descriptor_names: ClassVar[tuple[str, ...]] = () _supported_initialization_methods: ClassVar[tuple[InitializationMethodEnum, ...]] = ( InitializationMethodEnum.LATIN_HYPERCUBE, ) @@ -158,96 +139,6 @@ def _random_seed_descriptor() -> IntegerDescriptor: cif_handler=CifHandler(names=['_minimizer.random_seed']), ) - @staticmethod - def _runtime_seconds_descriptor() -> NumericDescriptor: - """Create a runtime-seconds descriptor.""" - return NumericDescriptor( - name='runtime_seconds', - description='Wall time of the fit in seconds.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_minimizer.runtime_seconds']), - ) - - @staticmethod - def _point_estimate_name_descriptor() -> StringDescriptor: - """Create a point-estimate-name descriptor.""" - return StringDescriptor( - name='point_estimate_name', - description='Committed sampled point estimate name.', - value_spec=AttributeSpec(default='best_sample'), - cif_handler=CifHandler(names=['_minimizer.point_estimate_name']), - ) - - @staticmethod - def _sampler_completed_descriptor() -> BoolDescriptor: - """Create a sampler-completed descriptor.""" - return BoolDescriptor( - name='sampler_completed', - description='Whether the sampler completed and returned posterior data.', - value_spec=AttributeSpec(default=False), - cif_handler=CifHandler(names=['_minimizer.sampler_completed']), - ) - - @staticmethod - def _credible_interval_inner_descriptor() -> NumericDescriptor: - """Create an inner credible-interval descriptor.""" - return NumericDescriptor( - name='credible_interval_inner', - description='Inner credible-interval level used in summaries.', - value_spec=AttributeSpec(default=0.68), - cif_handler=CifHandler(names=['_minimizer.credible_interval_inner']), - ) - - @staticmethod - def _credible_interval_outer_descriptor() -> NumericDescriptor: - """Create an outer credible-interval descriptor.""" - return NumericDescriptor( - name='credible_interval_outer', - description='Outer credible-interval level used in summaries.', - value_spec=AttributeSpec(default=0.95), - cif_handler=CifHandler(names=['_minimizer.credible_interval_outer']), - ) - - @staticmethod - def _acceptance_rate_mean_descriptor() -> NumericDescriptor: - """Create an acceptance-rate descriptor.""" - return NumericDescriptor( - name='acceptance_rate_mean', - description='Mean sampler acceptance rate.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_minimizer.acceptance_rate_mean']), - ) - - @staticmethod - def _gelman_rubin_max_descriptor() -> NumericDescriptor: - """Create a Gelman-Rubin descriptor.""" - return NumericDescriptor( - name='gelman_rubin_max', - description='Maximum rank-normalized split R-hat.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_minimizer.gelman_rubin_max']), - ) - - @staticmethod - def _effective_sample_size_min_descriptor() -> NumericDescriptor: - """Create an effective-sample-size descriptor.""" - return NumericDescriptor( - name='effective_sample_size_min', - description='Minimum bulk effective sample size.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_minimizer.effective_sample_size_min']), - ) - - @staticmethod - def _best_log_posterior_descriptor() -> NumericDescriptor: - """Create a best-log-posterior descriptor.""" - return NumericDescriptor( - name='best_log_posterior', - description='Best log-posterior value found.', - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=['_minimizer.best_log_posterior']), - ) - @property def sampling_steps(self) -> IntegerDescriptor: """Total sampler iterations per chain.""" @@ -315,90 +206,3 @@ def random_seed(self) -> IntegerDescriptor: @random_seed.setter def random_seed(self, value: int | None) -> None: self._random_seed.value = value - - @property - def runtime_seconds(self) -> NumericDescriptor: - """Wall time of the fit in seconds.""" - return self._runtime_seconds - - def _set_runtime_seconds(self, value: float | None) -> None: - """Set the fit runtime for internal callers.""" - self._runtime_seconds.value = value - - @property - def point_estimate_name(self) -> StringDescriptor: - """Committed sampled point estimate name.""" - return self._point_estimate_name - - def _set_point_estimate_name(self, value: str) -> None: - """Set the point-estimate name for internal callers.""" - self._point_estimate_name.value = value - - @property - def sampler_completed(self) -> BoolDescriptor: - """Whether the sampler completed and returned posterior data.""" - return self._sampler_completed - - def _set_sampler_completed(self, *, value: bool) -> None: - """Set the sampler-completed flag for internal callers.""" - self._sampler_completed.value = value - - @property - def credible_interval_inner(self) -> NumericDescriptor: - """Inner credible-interval level used in summaries.""" - return self._credible_interval_inner - - def _set_credible_interval_inner(self, value: float) -> None: - """ - Set the inner credible-interval level for internal callers. - """ - self._credible_interval_inner.value = value - - @property - def credible_interval_outer(self) -> NumericDescriptor: - """Outer credible-interval level used in summaries.""" - return self._credible_interval_outer - - def _set_credible_interval_outer(self, value: float) -> None: - """ - Set the outer credible-interval level for internal callers. - """ - self._credible_interval_outer.value = value - - @property - def acceptance_rate_mean(self) -> NumericDescriptor: - """Mean sampler acceptance rate.""" - return self._acceptance_rate_mean - - def _set_acceptance_rate_mean(self, value: float | None) -> None: - """Set the acceptance-rate mean for internal callers.""" - self._acceptance_rate_mean.value = value - - @property - def gelman_rubin_max(self) -> NumericDescriptor: - """Maximum rank-normalized split R-hat.""" - return self._gelman_rubin_max - - def _set_gelman_rubin_max(self, value: float | None) -> None: - """Set the maximum R-hat for internal callers.""" - self._gelman_rubin_max.value = value - - @property - def effective_sample_size_min(self) -> NumericDescriptor: - """Minimum bulk effective sample size.""" - return self._effective_sample_size_min - - def _set_effective_sample_size_min(self, value: float | None) -> None: - """ - Set the minimum effective sample size for internal callers. - """ - self._effective_sample_size_min.value = value - - @property - def best_log_posterior(self) -> NumericDescriptor: - """Best log-posterior value found.""" - return self._best_log_posterior - - def _set_best_log_posterior(self, value: float | None) -> None: - """Set the best log-posterior for internal callers.""" - self._best_log_posterior.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py index 2fb8caebd..13b844e86 100644 --- a/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py @@ -20,7 +20,7 @@ @MinimizerCategoryFactory.register class BumpsDreamMinimizer(BayesianMinimizerBase): - """Persisted settings and results for the BUMPS DREAM minimizer.""" + """Persisted settings for the BUMPS DREAM minimizer.""" _engine_metadata: ClassVar[dict[str, str]] = { 'optimizer_name': 'bumps (dream)', @@ -41,12 +41,3 @@ def __init__(self) -> None: self._parallel_workers = self._parallel_workers_descriptor(DEFAULT_PARALLEL_WORKERS) self._initialization_method = self._initialization_method_descriptor() self._random_seed = self._random_seed_descriptor() - self._runtime_seconds = self._runtime_seconds_descriptor() - self._point_estimate_name = self._point_estimate_name_descriptor() - self._sampler_completed = self._sampler_completed_descriptor() - self._credible_interval_inner = self._credible_interval_inner_descriptor() - self._credible_interval_outer = self._credible_interval_outer_descriptor() - self._acceptance_rate_mean = self._acceptance_rate_mean_descriptor() - self._gelman_rubin_max = self._gelman_rubin_max_descriptor() - self._effective_sample_size_min = self._effective_sample_size_min_descriptor() - self._best_log_posterior = self._best_log_posterior_descriptor() diff --git a/src/easydiffraction/analysis/categories/minimizer/lsq_base.py b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py index bbe72b0fa..60e2bc751 100644 --- a/src/easydiffraction/analysis/categories/minimizer/lsq_base.py +++ b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py @@ -6,13 +6,11 @@ from typing import ClassVar +from easydiffraction.analysis.categories.fit_result.lsq import LeastSquaresFitResult from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.validation import RangeValidator -from easydiffraction.core.variable import BoolDescriptor from easydiffraction.core.variable import IntegerDescriptor -from easydiffraction.core.variable import NumericDescriptor -from easydiffraction.core.variable import StringDescriptor from easydiffraction.io.cif.handler import CifHandler @@ -20,85 +18,17 @@ class LeastSquaresMinimizerBase(MinimizerCategoryBase): """Shared behavior for least-squares minimizer categories.""" _default_max_iterations: ClassVar[int] = 1000 - _expected_descriptor_names: ClassVar[tuple[str, ...]] = ( - 'max_iterations', - 'objective_name', - 'objective_value', - 'n_data_points', - 'n_parameters', - 'n_free_parameters', - 'degrees_of_freedom', - 'covariance_available', - 'correlation_available', - 'runtime_seconds', - 'iterations_performed', - 'exit_reason', - ) + _fit_result_class: ClassVar[type] = LeastSquaresFitResult + _expected_descriptor_names: ClassVar[tuple[str, ...]] = ('max_iterations',) _native_key_map: ClassVar[dict[str, str]] = { 'max_iterations': 'max_iterations', } _setting_descriptor_names: ClassVar[tuple[str, ...]] = ('max_iterations',) - _result_descriptor_names: ClassVar[tuple[str, ...]] = ( - 'objective_name', - 'objective_value', - 'n_data_points', - 'n_parameters', - 'n_free_parameters', - 'degrees_of_freedom', - 'covariance_available', - 'correlation_available', - 'runtime_seconds', - 'iterations_performed', - 'exit_reason', - ) + _result_descriptor_names: ClassVar[tuple[str, ...]] = () def __init__(self) -> None: super().__init__() self._max_iterations = self._max_iterations_descriptor(self._default_max_iterations) - self._objective_name = self._string_result_descriptor( - 'objective_name', - 'Objective function name for the persisted deterministic fit.', - ) - self._objective_value = self._numeric_result_descriptor( - 'objective_value', - 'Objective value for the persisted deterministic fit.', - ) - self._n_data_points = self._integer_result_descriptor( - 'n_data_points', - 'Number of data points used in the persisted deterministic fit.', - ) - self._n_parameters = self._integer_result_descriptor( - 'n_parameters', - 'Number of parameters considered in the persisted deterministic fit.', - ) - self._n_free_parameters = self._integer_result_descriptor( - 'n_free_parameters', - 'Number of free parameters in the persisted deterministic fit.', - ) - self._degrees_of_freedom = self._integer_result_descriptor( - 'degrees_of_freedom', - 'Degrees of freedom for the persisted deterministic fit.', - ) - self._covariance_available = self._bool_result_descriptor( - 'covariance_available', - 'Whether covariance was available for the persisted deterministic fit.', - ) - self._correlation_available = self._bool_result_descriptor( - 'correlation_available', - 'Whether correlations were available for the persisted deterministic fit.', - ) - self._runtime_seconds = self._numeric_result_descriptor( - 'runtime_seconds', - 'Runtime in seconds for the persisted deterministic fit.', - ) - self._iterations_performed = self._integer_result_descriptor( - 'iterations_performed', - 'Number of iterations performed by the persisted deterministic fit.', - ) - self._exit_reason = self._string_result_descriptor( - 'exit_reason', - 'Backend exit reason for the persisted deterministic fit.', - ) @staticmethod def _max_iterations_descriptor(default: int) -> IntegerDescriptor: @@ -110,71 +40,6 @@ def _max_iterations_descriptor(default: int) -> IntegerDescriptor: cif_handler=CifHandler(names=['_minimizer.max_iterations']), ) - @staticmethod - def _string_result_descriptor(name: str, description: str) -> StringDescriptor: - """ - Create a string-valued result descriptor. - - Defaults to ``None`` so a CIF written before any fit emits ``?`` - rather than an empty string, matching the "no fit happened yet" - semantics shared with the numeric/integer/bool variants. - """ - return StringDescriptor( - name=name, - description=description, - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=[f'_minimizer.{name}']), - ) - - @staticmethod - def _numeric_result_descriptor( - name: str, - description: str, - *, - default: float | None = None, - allow_none: bool = True, - ) -> NumericDescriptor: - """Create a numeric result descriptor.""" - return NumericDescriptor( - name=name, - description=description, - value_spec=AttributeSpec(default=default, allow_none=allow_none), - cif_handler=CifHandler(names=[f'_minimizer.{name}']), - ) - - @staticmethod - def _integer_result_descriptor(name: str, description: str) -> NumericDescriptor: - """ - Create an integer-like numeric result descriptor. - - Defaults to ``None`` so a CIF written before any fit emits ``?`` - rather than ``0``; the scientist audience reads ``0`` as a - degenerate result, not as "no fit yet". - """ - return NumericDescriptor( - name=name, - description=description, - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=[f'_minimizer.{name}']), - ) - - @staticmethod - def _bool_result_descriptor(name: str, description: str) -> BoolDescriptor: - """ - Create a boolean result descriptor. - - Defaults to ``None`` so a CIF written before any fit emits ``?`` - rather than ``false``; ``false`` would otherwise read as - "covariance/correlation was actively unavailable" instead of "no - fit happened yet". - """ - return BoolDescriptor( - name=name, - description=description, - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=[f'_minimizer.{name}']), - ) - @property def max_iterations(self) -> IntegerDescriptor: """Maximum solver iterations.""" @@ -183,97 +48,3 @@ def max_iterations(self) -> IntegerDescriptor: @max_iterations.setter def max_iterations(self, value: int) -> None: self._max_iterations.value = value - - @property - def objective_name(self) -> StringDescriptor: - """ - Objective function name for the persisted deterministic fit. - """ - return self._objective_name - - def _set_objective_name(self, value: str | None) -> None: - self._objective_name.value = value - - @property - def objective_value(self) -> NumericDescriptor: - """Objective value for the persisted deterministic fit.""" - return self._objective_value - - def _set_objective_value(self, value: float | None) -> None: - self._objective_value.value = value - - @property - def n_data_points(self) -> NumericDescriptor: - """ - Number of data points used in the persisted deterministic fit. - """ - return self._n_data_points - - def _set_n_data_points(self, value: float | None) -> None: - self._n_data_points.value = value - - @property - def n_parameters(self) -> NumericDescriptor: - """Number of parameters in the persisted deterministic fit.""" - return self._n_parameters - - def _set_n_parameters(self, value: float | None) -> None: - self._n_parameters.value = value - - @property - def n_free_parameters(self) -> NumericDescriptor: - """ - Number of free parameters in the persisted deterministic fit. - """ - return self._n_free_parameters - - def _set_n_free_parameters(self, value: float | None) -> None: - self._n_free_parameters.value = value - - @property - def degrees_of_freedom(self) -> NumericDescriptor: - """Degrees of freedom for the persisted deterministic fit.""" - return self._degrees_of_freedom - - def _set_degrees_of_freedom(self, value: float | None) -> None: - self._degrees_of_freedom.value = value - - @property - def covariance_available(self) -> BoolDescriptor: - """Whether deterministic covariance was available.""" - return self._covariance_available - - def _set_covariance_available(self, *, value: bool | None) -> None: - self._covariance_available.value = value - - @property - def correlation_available(self) -> BoolDescriptor: - """Whether deterministic correlations were available.""" - return self._correlation_available - - def _set_correlation_available(self, *, value: bool | None) -> None: - self._correlation_available.value = value - - @property - def runtime_seconds(self) -> NumericDescriptor: - """Runtime in seconds for the persisted deterministic fit.""" - return self._runtime_seconds - - def _set_runtime_seconds(self, value: float | None) -> None: - self._runtime_seconds.value = value - - @property - def iterations_performed(self) -> NumericDescriptor: - """Number of iterations performed by the deterministic fit.""" - return self._iterations_performed - - def _set_iterations_performed(self, value: float | None) -> None: - self._iterations_performed.value = value - - @property - def exit_reason(self) -> StringDescriptor: - """Backend exit reason for the persisted deterministic fit.""" - return self._exit_reason - - def _set_exit_reason(self, value: str | None) -> None: - self._exit_reason.value = value diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py index 2c48f40fc..ce9fe54b2 100644 --- a/src/easydiffraction/analysis/sequential.py +++ b/src/easydiffraction/analysis/sequential.py @@ -94,83 +94,80 @@ def _fit_worker( ``reduced_chi_square``, ``iterations``, and per-parameter ``{unique_name}`` / ``{unique_name}.uncertainty``. """ + try: + return _fit_worker_success(template, data_path) + except ( + RuntimeError, + ValueError, + TypeError, + ArithmeticError, + KeyError, + IndexError, + OSError, + ) as exc: + return _fit_worker_error(data_path, exc) + + +def _fit_worker_success( + template: SequentialFitTemplate, + data_path: str, +) -> dict[str, Any]: + """Run one sequential-fit worker and return collected results.""" # Lazy import to avoid circular dependencies and keep the module # importable without heavy imports at top level. from easydiffraction.project.project import Project # noqa: PLC0415 result: dict[str, Any] = {'file_path': data_path} + Project._loading = True try: - # 1. Create a fresh, isolated project - Project._loading = True - try: - project = Project(name='_worker') - finally: - Project._loading = False - - # 2. Load structure from template CIF - project.structures.add_from_cif_str(template.structure_cif) - - # 3. Load experiment from template CIF - # (full config + template data) - project.experiments.add_from_cif_str(template.experiment_cif) - expt = next(iter(project.experiments.values())) - - # 4. Replace data from the new data path - expt._load_ascii_data_to_experiment(data_path) - - # 5. Extract diffrn metadata from the data file - result.update(_extract_diffrn_values(expt, data_path, template.diffrn_extract_rules)) - - # 6. Override parameter values from propagated starting values - _apply_param_overrides(project, template.initial_params) - - # 7. Set free flags - _set_free_params(project, template.free_param_unique_names) - - # 8. Apply constraints - if template.constraints_enabled and template.alias_defs: - _apply_constraints( - project, - template.alias_defs, - template.constraint_defs, - ) - - # 9. Set calculator and minimizer - # (internal, no console output) - from easydiffraction.analysis.fitting import Fitter # noqa: PLC0415 + project = Project(name='_worker') + finally: + Project._loading = False + + project.structures.add_from_cif_str(template.structure_cif) + project.experiments.add_from_cif_str(template.experiment_cif) + expt = next(iter(project.experiments.values())) + expt._load_ascii_data_to_experiment(data_path) + result.update(_extract_diffrn_values(expt, data_path, template.diffrn_extract_rules)) + + _apply_param_overrides(project, template.initial_params) + _set_free_params(project, template.free_param_unique_names) + if template.constraints_enabled and template.alias_defs: + _apply_constraints( + project, + template.alias_defs, + template.constraint_defs, + ) - expt._swap_calculator(template.calculator_tag, announce=False) - project.analysis.fitter = Fitter(template.minimizer_tag) + from easydiffraction.analysis.fitting import Fitter # noqa: PLC0415 - # 10. Fit - original_verbosity = project.verbosity.fit.value - project.verbosity.fit = 'silent' - try: - project.analysis.fit() - finally: - project.verbosity.fit = original_verbosity + expt._swap_calculator(template.calculator_tag, announce=False) + project.analysis.fitter = Fitter(template.minimizer_tag) - # 11. Collect results - result.update(_collect_results(project, template)) + original_verbosity = project.verbosity.fit.value + project.verbosity.fit = 'silent' + try: + project.analysis.fit() + finally: + project.verbosity.fit = original_verbosity - except ( - RuntimeError, - ValueError, - TypeError, - ArithmeticError, - KeyError, - IndexError, - OSError, - ) as exc: - result['success'] = False - result['reduced_chi_square'] = None - result['iterations'] = 0 - result['error'] = str(exc) + result.update(_collect_results(project, template)) return result +def _fit_worker_error(data_path: str, exc: Exception) -> dict[str, Any]: + """Return the standard failed worker result payload.""" + return { + 'file_path': data_path, + 'success': False, + 'reduced_chi_square': None, + 'iterations': 0, + 'error': str(exc), + } + + # ------------------------------------------------------------------ # Helper functions # ------------------------------------------------------------------ diff --git a/src/easydiffraction/core/singleton.py b/src/easydiffraction/core/singleton.py index 9f997e535..95295a9ed 100644 --- a/src/easydiffraction/core/singleton.py +++ b/src/easydiffraction/core/singleton.py @@ -112,28 +112,27 @@ def apply(self) -> None: for lhs_alias, rhs_expr in self._parsed_constraints: try: - # Evaluate the RHS expression using the current values - rhs_value = ae(rhs_expr) - - # asteval silently returns None for undefined names - # instead of raising an exception; errors are stored in - # ae.error. - if ae.error: - error_msgs = '; '.join(str(e.get_error()) for e in ae.error) - ae.error.clear() - log.error( - f"Constraint '{lhs_alias} = {rhs_expr}' could not be " - f'evaluated: {error_msgs}. ' - f'Make sure every name in the expression is registered ' - f'as an alias via analysis.aliases.create().', - exc_type=ValueError, - ) - - # Get the actual parameter object we want to update - param = self._alias_to_param[lhs_alias].param - - # Update its value and mark it as user constrained - param._set_value_user_constrained(rhs_value) - + self._apply_one_constraint(ae, lhs_alias, rhs_expr) except (ValueError, TypeError, ArithmeticError, KeyError, AttributeError) as error: print(f"Failed to apply constraint '{lhs_alias} = {rhs_expr}': {error}") + + def _apply_one_constraint( + self, + ae: Interpreter, + lhs_alias: str, + rhs_expr: str, + ) -> None: + """Evaluate and apply one parsed constraint expression.""" + rhs_value = ae(rhs_expr) + if ae.error: + error_msgs = '; '.join(str(e.get_error()) for e in ae.error) + ae.error.clear() + log.error( + f"Constraint '{lhs_alias} = {rhs_expr}' could not be " + f'evaluated: {error_msgs}. ' + f'Make sure every name in the expression is registered ' + f'as an alias via analysis.aliases.create().', + exc_type=ValueError, + ) + param = self._alias_to_param[lhs_alias].param + param._set_value_user_constrained(rhs_value) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index cae993fd0..f96c812a4 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -3684,37 +3684,18 @@ def _evaluate_posterior_predictive_draws( dtype=float, ) original_uncertainties = [parameter.uncertainty for parameter in sampled_parameters] - predictive_draws: list[np.ndarray] = [] draw_indices = self._posterior_predictive_draw_indices(flattened_samples.shape[0]) try: - best_sample_prediction, x_values = self._evaluate_posterior_predictive_state( + evaluated = self._evaluate_posterior_predictive_draw_values( + draw_indices=draw_indices, + flattened_samples=flattened_samples, sampled_parameters=sampled_parameters, - values=original_values, experiment=experiment, expt_name=expt_name, x_axis=x_axis, + original_values=original_values, ) - if best_sample_prediction is None or x_values is None: - return None - - for index in draw_indices: - prediction, current_x = self._evaluate_posterior_predictive_state( - sampled_parameters=sampled_parameters, - values=flattened_samples[index], - experiment=experiment, - expt_name=expt_name, - x_axis=x_axis, - ) - if prediction is None or current_x is None: - return None - if ( - prediction.shape != best_sample_prediction.shape - or current_x.shape != x_values.shape - ): - log.warning('Posterior predictive draws returned inconsistent array shapes.') - return None - predictive_draws.append(prediction) finally: self._restore_posterior_predictive_parameters( sampled_parameters=sampled_parameters, @@ -3723,12 +3704,58 @@ def _evaluate_posterior_predictive_draws( expt_name=expt_name, ) + if evaluated is None: + return None + best_sample_prediction, x_values, predictive_draws = evaluated return ( np.asarray(best_sample_prediction, dtype=float), np.asarray(x_values, dtype=float), np.asarray(predictive_draws, dtype=float), ) + def _evaluate_posterior_predictive_draw_values( + self, + *, + draw_indices: np.ndarray, + flattened_samples: np.ndarray, + sampled_parameters: list[object], + experiment: object, + expt_name: str, + x_axis: object, + original_values: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, list[np.ndarray]] | None: + """Evaluate posterior predictive best sample and draw curves.""" + best_sample_prediction, x_values = self._evaluate_posterior_predictive_state( + sampled_parameters=sampled_parameters, + values=original_values, + experiment=experiment, + expt_name=expt_name, + x_axis=x_axis, + ) + if best_sample_prediction is None or x_values is None: + return None + + predictive_draws: list[np.ndarray] = [] + for index in draw_indices: + prediction, current_x = self._evaluate_posterior_predictive_state( + sampled_parameters=sampled_parameters, + values=flattened_samples[index], + experiment=experiment, + expt_name=expt_name, + x_axis=x_axis, + ) + if prediction is None or current_x is None: + return None + if ( + prediction.shape != best_sample_prediction.shape + or current_x.shape != x_values.shape + ): + log.warning('Posterior predictive draws returned inconsistent array shapes.') + return None + predictive_draws.append(prediction) + + return best_sample_prediction, x_values, predictive_draws + def _restore_posterior_predictive_parameters( self, *, diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py index 929b2217c..fcd209d08 100644 --- a/src/easydiffraction/io/cif/serialize.py +++ b/src/easydiffraction/io/cif/serialize.py @@ -569,11 +569,7 @@ def analysis_from_cif(analysis: object, cif_text: str) -> None: def _has_persisted_fit_state_sections(block: object) -> bool: """Return True when any persisted fit-state section is present.""" - scalar_tags = ( - '_fit_result.result_kind', - '_minimizer.runtime_seconds', - '_minimizer.best_log_posterior', - ) + scalar_tags = ('_fit_result.result_kind',) loop_tags = ( '_fit_parameter.param_unique_name', '_fit_parameter_correlation.param_unique_name_i', @@ -610,6 +606,29 @@ def _restore_persisted_fit_state(analysis: object, block: object) -> None: ) +_MINIMIZER_OUTPUT_LEGACY_TAGS = ( + '_minimizer.objective_name', + '_minimizer.objective_value', + '_minimizer.n_data_points', + '_minimizer.n_parameters', + '_minimizer.n_free_parameters', + '_minimizer.degrees_of_freedom', + '_minimizer.covariance_available', + '_minimizer.correlation_available', + '_minimizer.runtime_seconds', + '_minimizer.iterations_performed', + '_minimizer.exit_reason', + '_minimizer.point_estimate_name', + '_minimizer.sampler_completed', + '_minimizer.credible_interval_inner', + '_minimizer.credible_interval_outer', + '_minimizer.acceptance_rate_mean', + '_minimizer.gelman_rubin_max', + '_minimizer.effective_sample_size_min', + '_minimizer.best_log_posterior', +) + + def _collect_legacy_analysis_tags(block: object) -> list[str]: """Return deprecated analysis CIF tags present in a block.""" legacy_tags: list[str] = [] @@ -617,6 +636,7 @@ def _collect_legacy_analysis_tags(block: object) -> list[str]: legacy_tags.append('_joint_fit_experiment.id') if _has_cif_loop(block, '_joint_fit_experiment.weight'): legacy_tags.append('_joint_fit_experiment.weight') + legacy_tags.extend(tag for tag in _MINIMIZER_OUTPUT_LEGACY_TAGS if _has_cif_value(block, tag)) return legacy_tags @@ -629,7 +649,8 @@ def _raise_for_legacy_analysis_tags(block: object) -> None: msg = ( 'Legacy analysis CIF tags are no longer supported: ' f'{legacy_tags}. Use _minimizer.type, _fitting_mode.type, ' - '_minimizer.*, _joint_fit.experiment_id, and _joint_fit.weight.' + '_minimizer.* for settings, _fit_result.* for fit outputs, ' + '_joint_fit.experiment_id, and _joint_fit.weight.' ) raise ValueError(msg) diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index 168d16773..5067e40d3 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -17,6 +17,7 @@ from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING from easydiffraction.display.progress import activity_indicator from easydiffraction.utils.enums import VerbosityEnum +from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log from easydiffraction.utils.utils import render_object_help from easydiffraction.utils.utils import render_table @@ -87,7 +88,39 @@ def __init__(self, project: Project) -> None: def results(self) -> None: """Show the latest fit summary and fitted parameter table.""" - self._project.analysis.display.fit_results() + analysis = self._project.analysis + if analysis.fit_results is None: + analysis.display.fit_results() + return + + self._show_settings_used() + analysis.display.fit_results() + + def _show_settings_used(self) -> None: + """Show minimizer settings used for the latest fit.""" + rows = self._settings_used_rows() + if not rows: + return + + console.paragraph('Settings used') + render_table( + columns_headers=['Name', 'Value', 'Description'], + columns_alignment=['left', 'right', 'left'], + columns_data=rows, + ) + + def _settings_used_rows(self) -> list[list[str]]: + """Return minimizer setting rows for display.""" + minimizer = self._project.analysis.minimizer + rows: list[list[str]] = [] + for name in minimizer._setting_descriptor_names: + descriptor = getattr(minimizer, name) + rows.append([ + name, + str(descriptor.value), + descriptor.description or '', + ]) + return rows def correlations( self, diff --git a/tests/integration/scipp-analysis/dream/test_package_import.py b/tests/integration/scipp-analysis/dream/test_package_import.py index 45b5bc95b..d17da2007 100644 --- a/tests/integration/scipp-analysis/dream/test_package_import.py +++ b/tests/integration/scipp-analysis/dream/test_package_import.py @@ -3,17 +3,16 @@ """Tests for verifying package installation and version consistency. -These tests check that easydiffraction and essdiffraction packages are -installed and are not older than the latest PyPI release. +These tests check that easydiffraction is installed and can be found on +PyPI. """ import importlib.metadata import pytest import requests -from packaging.version import Version -PACKAGE_NAMES = ['easydiffraction', 'essdiffraction'] +PACKAGE_NAMES = ['easydiffraction'] PYPI_URL = 'https://pypi.org/pypi/{}/json' @@ -37,16 +36,6 @@ def get_latest_version( return None -def get_base_version( - version_str: str, -) -> str: - """Extract MAJOR.MINOR.PATCH from version string, ignoring local - identifiers. - """ - v = Version(version_str) - return v.base_version - - @pytest.mark.parametrize('package_name', PACKAGE_NAMES) def test_package_import( package_name: str, diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py new file mode 100644 index 000000000..410754e1b --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for common fit-result status metadata.""" + +from __future__ import annotations + + +def test_fit_result_base_defaults_unknown_result_values_to_none(): + from easydiffraction.analysis.categories.fit_result.base import FitResultBase + from easydiffraction.analysis.enums import FitResultKindEnum + + fit_result = FitResultBase() + + assert fit_result.result_kind.value == FitResultKindEnum.DETERMINISTIC.value + assert fit_result.success.value is None + assert fit_result.message.value is None + assert fit_result.iterations.value is None + assert fit_result.fitting_time.value is None + assert fit_result.reduced_chi_square.value is None + + +def test_fit_result_base_reset_restores_declared_defaults(): + from easydiffraction.analysis.categories.fit_result.base import FitResultBase + + fit_result = FitResultBase() + fit_result._set_result_kind('bayesian') + fit_result._set_success(value=True) + fit_result._set_message('Fit converged') + fit_result._set_iterations(14) + fit_result._set_fitting_time(0.25) + fit_result._set_reduced_chi_square(1.2) + + fit_result._reset_result_descriptors() + + assert fit_result.result_kind.value == 'deterministic' + assert fit_result.success.value is None + assert fit_result.message.value is None + assert fit_result.iterations.value is None + assert fit_result.fitting_time.value is None + assert fit_result.reduced_chi_square.value is None + + +def test_fit_result_base_serializes_unknown_values_as_cif_unknowns(): + from easydiffraction.analysis.categories.fit_result.base import FitResultBase + + cif_text = FitResultBase().as_cif + + assert '_fit_result.success ?' in cif_text + assert '_fit_result.message ?' in cif_text + assert '_fit_result.iterations ?' in cif_text diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py new file mode 100644 index 000000000..40c1a2657 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for Bayesian fit-result metadata.""" + +from __future__ import annotations + +import gemmi + + +def test_bayesian_fit_result_defaults_unknown_outputs_to_none(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + + fit_result = BayesianFitResult() + + assert fit_result.point_estimate_name.value is None + assert fit_result.sampler_completed.value is None + assert fit_result.credible_interval_inner.value == 0.68 + assert fit_result.credible_interval_outer.value == 0.95 + assert fit_result.acceptance_rate_mean.value is None + assert fit_result.gelman_rubin_max.value is None + assert fit_result.effective_sample_size_min.value is None + assert fit_result.best_log_posterior.value is None + + +def test_bayesian_fit_result_round_trips_cif_outputs(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + + fit_result = BayesianFitResult() + fit_result._set_point_estimate_name('posterior_median') + fit_result._set_sampler_completed(value=True) + fit_result._set_credible_interval_inner(0.5) + fit_result._set_credible_interval_outer(0.9) + fit_result._set_acceptance_rate_mean(0.42) + fit_result._set_gelman_rubin_max(1.01) + fit_result._set_effective_sample_size_min(80) + fit_result._set_best_log_posterior(-12.5) + + restored = BayesianFitResult() + restored.from_cif(gemmi.cif.read_string(f'data_fit_result\n{fit_result.as_cif}').sole_block()) + + assert restored.point_estimate_name.value == 'posterior_median' + assert restored.sampler_completed.value is True + assert restored.credible_interval_inner.value == 0.5 + assert restored.credible_interval_outer.value == 0.9 + assert restored.acceptance_rate_mean.value == 0.42 + assert restored.gelman_rubin_max.value == 1.01 + assert restored.effective_sample_size_min.value == 80 + assert restored.best_log_posterior.value == -12.5 diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py new file mode 100644 index 000000000..7352301fb --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for fit-result factory registration and pairing.""" + +from __future__ import annotations + + +def test_fit_result_factory_creates_registered_family_classes(): + import easydiffraction.analysis.categories.fit_result # noqa: F401 + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + + assert isinstance(FitResultFactory.create('least_squares'), LeastSquaresFitResult) + assert isinstance(FitResultFactory.create('bayesian'), BayesianFitResult) + + +def test_minimizer_bases_declare_paired_fit_result_classes(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + from easydiffraction.analysis.categories.minimizer.bayesian_base import ( + BayesianMinimizerBase, + ) + from easydiffraction.analysis.categories.minimizer.lsq_base import ( + LeastSquaresMinimizerBase, + ) + + assert LeastSquaresMinimizerBase._fit_result_class is LeastSquaresFitResult + assert BayesianMinimizerBase._fit_result_class is BayesianFitResult diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py new file mode 100644 index 000000000..bce6e3670 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for least-squares fit-result metadata.""" + +from __future__ import annotations + +import gemmi + + +def test_least_squares_fit_result_defaults_unknown_outputs_to_none(): + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + + fit_result = LeastSquaresFitResult() + + assert fit_result.objective_name.value is None + assert fit_result.objective_value.value is None + assert fit_result.n_data_points.value is None + assert fit_result.n_parameters.value is None + assert fit_result.n_free_parameters.value is None + assert fit_result.degrees_of_freedom.value is None + assert fit_result.covariance_available.value is None + assert fit_result.correlation_available.value is None + assert fit_result.exit_reason.value is None + + +def test_least_squares_fit_result_round_trips_cif_outputs(): + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + + fit_result = LeastSquaresFitResult() + fit_result._set_objective_name('chi-square') + fit_result._set_objective_value(1.25) + fit_result._set_n_data_points(120) + fit_result._set_n_parameters(4) + fit_result._set_n_free_parameters(3) + fit_result._set_degrees_of_freedom(117) + fit_result._set_covariance_available(value=True) + fit_result._set_correlation_available(value=False) + fit_result._set_exit_reason('converged') + + restored = LeastSquaresFitResult() + restored.from_cif(gemmi.cif.read_string(f'data_fit_result\n{fit_result.as_cif}').sole_block()) + + assert restored.objective_name.value == 'chi-square' + assert restored.objective_value.value == 1.25 + assert restored.n_data_points.value == 120 + assert restored.n_parameters.value == 4 + assert restored.n_free_parameters.value == 3 + assert restored.degrees_of_freedom.value == 117 + assert restored.covariance_available.value is True + assert restored.correlation_available.value is False + assert restored.exit_reason.value == 'converged' diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py index 2dcf16563..011862282 100644 --- a/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_lsq_base.py @@ -7,32 +7,19 @@ import gemmi -def test_lsq_minimizer_defaults_and_result_reset(): +def test_lsq_minimizer_defaults_to_settings_only(): from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( LmfitLeastsqMinimizer, ) minimizer = LmfitLeastsqMinimizer() - minimizer._set_objective_name('chi-square') - minimizer._set_objective_value(1.2) - minimizer._set_covariance_available(value=True) assert minimizer.max_iterations.value == 1000 - assert minimizer.objective_name.value == 'chi-square' + assert minimizer._setting_descriptor_names == ('max_iterations',) + assert minimizer._result_descriptor_names == () - minimizer._reset_result_descriptors() - # LSQ result descriptors default to None so a CIF written before - # any fit emits `?` rather than `''`, `0`, or `False`. See - # minimizer-category-consolidation_review-8 finding F6. - assert minimizer.objective_name.value is None - assert minimizer.objective_value.value is None - assert minimizer.covariance_available.value is None - assert minimizer.n_data_points.value is None - assert minimizer.iterations_performed.value is None - - -def test_lsq_minimizer_reads_cif_unknown_values_as_defaults(): +def test_lsq_minimizer_reads_cif_settings(): from easydiffraction.analysis.categories.minimizer.lmfit_leastsq import ( LmfitLeastsqMinimizer, ) @@ -40,13 +27,9 @@ def test_lsq_minimizer_reads_cif_unknown_values_as_defaults(): document = gemmi.cif.read_string( """data_minimizer _minimizer.max_iterations 42 -_minimizer.objective_name chi-square -_minimizer.objective_value ? """ ) minimizer = LmfitLeastsqMinimizer() minimizer.from_cif(document.sole_block()) assert minimizer.max_iterations.value == 42 - assert minimizer.objective_name.value == 'chi-square' - assert minimizer.objective_value.value is None diff --git a/tests/unit/easydiffraction/analysis/categories/test_fit_result.py b/tests/unit/easydiffraction/analysis/categories/test_fit_result.py index 4c9d869dc..580635c8c 100644 --- a/tests/unit/easydiffraction/analysis/categories/test_fit_result.py +++ b/tests/unit/easydiffraction/analysis/categories/test_fit_result.py @@ -4,10 +4,10 @@ def test_fit_result_factory_create(): - from easydiffraction.analysis.categories.fit_result.default import FitResult + from easydiffraction.analysis.categories.fit_result.base import FitResultBase from easydiffraction.analysis.categories.fit_result.factory import FitResultFactory fit_result = FitResultFactory.create('default') assert FitResultFactory.default_tag() == 'default' - assert isinstance(fit_result, FitResult) + assert isinstance(fit_result, FitResultBase) diff --git a/tests/unit/easydiffraction/analysis/categories/test_fit_state.py b/tests/unit/easydiffraction/analysis/categories/test_fit_state.py index a26d4435b..a3b4fd7cc 100644 --- a/tests/unit/easydiffraction/analysis/categories/test_fit_state.py +++ b/tests/unit/easydiffraction/analysis/categories/test_fit_state.py @@ -36,9 +36,9 @@ def test_fit_parameter_collection_serializes_expected_tags_and_values(): def test_fit_result_serializes_expected_tags_and_enum_value(): - from easydiffraction.analysis.categories.fit_result.default import FitResult + from easydiffraction.analysis.categories.fit_result.base import FitResultBase - fit_result = FitResult() + fit_result = FitResultBase() fit_result._set_result_kind('bayesian') fit_result._set_success(value=True) fit_result._set_message('Sampler completed') @@ -138,7 +138,10 @@ def test_fit_parameter_posterior_summary_serializes_expected_tags(): assert summary.ess_bulk == 120 -def test_dream_minimizer_sampler_and_diagnostics_use_cif_fields(): +def test_dream_sampler_settings_and_diagnostics_use_split_cif_fields(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) from easydiffraction.analysis.categories.minimizer.bumps_dream import ( BumpsDreamMinimizer, ) @@ -148,15 +151,17 @@ def test_dream_minimizer_sampler_and_diagnostics_use_cif_fields(): minimizer.burn_in_steps = 20 minimizer.parallel_workers = 0 minimizer.random_seed = 123 - minimizer._set_gelman_rubin_max(1.01) - minimizer._set_effective_sample_size_min(80) + fit_result = BayesianFitResult() + fit_result._set_gelman_rubin_max(1.01) + fit_result._set_effective_sample_size_min(80) - cif_text = minimizer.as_cif + minimizer_cif_text = minimizer.as_cif + fit_result_cif_text = fit_result.as_cif - assert '_minimizer.sampling_steps 100' in cif_text - assert '_minimizer.parallel_workers 0' in cif_text - assert '_minimizer.random_seed 123' in cif_text - assert '_minimizer.effective_sample_size_min' in cif_text + assert '_minimizer.sampling_steps 100' in minimizer_cif_text + assert '_minimizer.parallel_workers 0' in minimizer_cif_text + assert '_minimizer.random_seed 123' in minimizer_cif_text + assert '_fit_result.effective_sample_size_min' in fit_result_cif_text def test_fit_parameter_posteriors_preserve_row_order_from_cif(): diff --git a/tests/unit/easydiffraction/io/test_results_sidecar.py b/tests/unit/easydiffraction/io/test_results_sidecar.py index 70dbef7d6..75dbe571e 100644 --- a/tests/unit/easydiffraction/io/test_results_sidecar.py +++ b/tests/unit/easydiffraction/io/test_results_sidecar.py @@ -17,9 +17,11 @@ def _analysis_with_sidecar_payload( include_pair: bool = True, include_predictive: bool = True, ) -> object: - from easydiffraction.analysis.categories.fit_result.default import FitResult + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) - fit_result = FitResult() + fit_result = BayesianFitResult() fit_result._set_result_kind('bayesian') posterior_samples = None diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index 23de62edb..f80d243e4 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -54,6 +54,7 @@ def _recorder(*args, **kwargs): analysis=SimpleNamespace( display=analysis_display, fit_results=SimpleNamespace(posterior_predictive={}), + minimizer=SimpleNamespace(_setting_descriptor_names=()), bayesian_result=SimpleNamespace( has_pair_cache=SimpleNamespace(value=False), has_posterior_predictive=SimpleNamespace(value=False), diff --git a/tests/unit/easydiffraction/project/test_project_load.py b/tests/unit/easydiffraction/project/test_project_load.py index c726172f4..cfbd7652d 100644 --- a/tests/unit/easydiffraction/project/test_project_load.py +++ b/tests/unit/easydiffraction/project/test_project_load.py @@ -211,14 +211,14 @@ def test_round_trips_persisted_deterministic_correlation_summary_for_reloaded_di original.analysis.fit_result._set_iterations(21) original.analysis.fit_result._set_fitting_time(0.74) original.analysis.fit_result._set_reduced_chi_square(1.031) - original.analysis.minimizer._set_objective_name('chi-square') - original.analysis.minimizer._set_objective_value(1.031) - original.analysis.minimizer._set_n_data_points(120) - original.analysis.minimizer._set_n_parameters(2) - original.analysis.minimizer._set_n_free_parameters(2) - original.analysis.minimizer._set_degrees_of_freedom(118) - original.analysis.minimizer._set_covariance_available(value=False) - original.analysis.minimizer._set_correlation_available(value=True) + original.analysis.fit_result._set_objective_name('chi-square') + original.analysis.fit_result._set_objective_value(1.031) + original.analysis.fit_result._set_n_data_points(120) + original.analysis.fit_result._set_n_parameters(2) + original.analysis.fit_result._set_n_free_parameters(2) + original.analysis.fit_result._set_degrees_of_freedom(118) + original.analysis.fit_result._set_covariance_available(value=False) + original.analysis.fit_result._set_correlation_available(value=True) original.analysis.fit_parameter_correlations.create( source_kind='deterministic', param_unique_name_i=parameter_b.unique_name, From a06f7076a52277f7709120c8b5c5c03839fdfc15 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 26 May 2026 00:20:20 +0200 Subject: [PATCH 03/12] Add emcee Bayesian sampler with resumable runs (#182) * Refresh emcee plan for post-merge surface * Document /draft-adr /review-adr /draft-plan /review-plan shortcuts * Sync copilot-instructions with current API and branch conventions * Refine shortcut triggers and loop behavior * Add emcee minimizer implementation plan * Add /implement-plan and switch shortcuts to sentinel-based handoff * Clean up and formatting * Add emcee runtime dependency * Register emcee minimizer enum value * Remove emcee version pin * Add EmceeMinimizer category class * Add EmceeMinimizer engine class * Wire emcee resume through fit stack * Append-on-save plus explicit truncate-on-new-fit prep * Route emcee posterior through fit_result and sidecar * Add ed-25 emcee tutorial * Mark emcee phase 1 review gate * Improve emcee sampler progress reporting * Restore emcee default parallel sampling * Align emcee sampling progress totals * Trim deterministic fit-parameter CIF columns * Omit empty fit bounds multiplier column * Suppress duplicate LSQ exit reason * Persist Bayesian runtime seed and timing * Add notebook stop control for fitting * Use Jupyter server interrupt for stop fitting * Clear stop fitting button on interrupt * Default emcee resume steps from minimizer settings * Fallback to fresh emcee fit when resume chain is missing * Resume emcee from persisted sampler state * Update emcee default proposal moves and thinning * Introduce emcee minimizer tutorials * Format switch warnings as bullet lists * Improve fitting output and peak warnings * Add ADR on fit results display naming * Replace arviz with custom diagnostics * Update tutorial archives and reduce steps * Update notebooks * Share emcee minimizer defaults * Remove unused sidecar warning wrapper * Keep DREAM parallel worker syncing * Use native emcee sampler setting keys * Document emcee tutorial split * Use eager emcee sidecar imports * Clarify emcee integration test phase * Remove redundant emcee initialization map * Apply non-py formatting * Update notebooks * Add emcee phase 2 verification tests * Apply pixi run fix auto-fixes * Apply remaining pixi run fix auto-fixes * Complete emcee static checks * Record emcee unit test verification * Fix emcee result synchronization * Add live elapsed time to benchmark runs * Isolate sequential tutorial project path * Document emcee fit options refinement * Record emcee Phase 2 bug-fix note * Document emcee benchmark tooling note * Expand emcee minimizer category tests * Document emcee tutorial verification note * Improve benchmark tutorial progress table * Update tutorial benchmarks for emcee minimizer * Clean up * Formatting --- .github/copilot-instructions.md | 253 ---- .gitignore | 4 + AGENTS.md | 5 - CLAUDE.md | 1 - .../adrs/accepted/analysis-cif-fit-state.md | 21 +- .../accepted/fit-results-display-naming.md | 336 +++++ .../minimizer-category-consolidation.md | 68 +- .../accepted/minimizer-input-output-split.md | 2 +- .../switchable-category-owned-selectors.md | 43 +- docs/dev/adrs/index.md | 1 + ...darwin-arm64_py314_tutorial-benchmarks.csv | 48 +- ...darwin-arm64_py314_tutorial-benchmarks.csv | 48 +- ...darwin-arm64_py314_tutorial-benchmarks.csv | 48 +- ...darwin-arm64_py314_tutorial-benchmarks.csv | 26 + docs/dev/issues/closed.md | 26 + docs/dev/issues/open.md | 67 - docs/dev/package-structure/full.md | 14 +- docs/dev/package-structure/short.md | 4 + docs/dev/plans/emcee-minimizer.md | 285 ---- .../dev/plans/minimizer-input-output-split.md | 674 --------- docs/docs/tutorials/ed-17.ipynb | 2 +- docs/docs/tutorials/ed-17.py | 2 +- docs/docs/tutorials/ed-21.ipynb | 4 +- docs/docs/tutorials/ed-21.py | 4 +- docs/docs/tutorials/ed-22.ipynb | 124 +- docs/docs/tutorials/ed-22.py | 29 +- docs/docs/tutorials/ed-24.ipynb | 49 +- docs/docs/tutorials/ed-24.py | 48 +- docs/docs/tutorials/ed-25.ipynb | 749 ++++++++++ docs/docs/tutorials/ed-25.py | 349 +++++ docs/docs/tutorials/ed-26.ipynb | 313 ++++ docs/docs/tutorials/ed-26.py | 127 ++ docs/docs/tutorials/ed-5.ipynb | 62 +- docs/docs/tutorials/ed-5.py | 3 + docs/docs/tutorials/index.md | 50 +- docs/mkdocs.yml | 8 +- pixi.lock | 650 ++++----- pixi.toml | 3 + pyproject.toml | 2 +- src/easydiffraction/analysis/analysis.py | 557 ++++++-- .../categories/fit_parameters/default.py | 53 + .../categories/fit_result/bayesian.py | 43 + .../analysis/categories/fit_result/lsq.py | 15 + .../analysis/categories/minimizer/__init__.py | 1 + .../analysis/categories/minimizer/base.py | 1 + .../analysis/categories/minimizer/emcee.py | 121 ++ .../analysis/fit_helpers/_diagnostics.py | 174 +++ .../analysis/fit_helpers/bayesian.py | 316 ++-- .../analysis/fit_helpers/reporting.py | 114 +- src/easydiffraction/analysis/fitting.py | 101 +- .../analysis/minimizers/__init__.py | 1 + .../analysis/minimizers/base.py | 45 +- .../analysis/minimizers/bumps_dream.py | 3 + .../analysis/minimizers/emcee.py | 1271 +++++++++++++++++ .../analysis/minimizers/emcee_defaults.py | 26 + .../analysis/minimizers/enums.py | 2 + src/easydiffraction/analysis/sequential.py | 3 +- .../datablocks/experiment/item/base.py | 71 +- src/easydiffraction/display/plotting.py | 45 +- src/easydiffraction/display/progress.py | 278 ++++ src/easydiffraction/io/cif/serialize.py | 28 +- src/easydiffraction/io/results_sidecar.py | 36 +- src/easydiffraction/project/display.py | 7 +- src/easydiffraction/project/project.py | 4 +- src/easydiffraction/utils/logging.py | 73 + src/easydiffraction/utils/utils.py | 142 +- .../fitting/test_bayesian_dream.py | 4 +- .../fitting/test_bayesian_helper_support.py | 160 +-- .../fitting/test_bayesian_tracker_and_base.py | 11 +- tests/integration/fitting/test_emcee.py | 122 ++ .../categories/fit_result/test_bayesian.py | 42 + .../categories/fit_result/test_lsq.py | 30 + .../minimizer/test_bayesian_base.py | 23 + .../categories/minimizer/test_emcee.py | 145 ++ .../categories/test_fit_parameters.py | 72 + .../analysis/fit_helpers/test__diagnostics.py | 108 ++ .../analysis/fit_helpers/test_bayesian.py | 67 +- .../analysis/fit_helpers/test_reporting.py | 27 +- .../analysis/fit_helpers/test_tracking.py | 79 + .../analysis/minimizers/test_base.py | 3 +- .../analysis/minimizers/test_emcee.py | 429 ++++++ .../minimizers/test_emcee_defaults.py | 20 + .../easydiffraction/analysis/test_analysis.py | 262 +++- .../easydiffraction/analysis/test_fitting.py | 16 +- .../analysis/test_sequential.py | 8 +- .../datablocks/experiment/item/test_base.py | 106 ++ .../io/test_results_sidecar.py | 26 + .../unit/easydiffraction/utils/test_utils.py | 81 ++ tests/unit/test_benchmark_tutorials.py | 14 +- tools/benchmark_tutorials.py | 204 ++- 90 files changed, 7581 insertions(+), 2561 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 docs/dev/adrs/accepted/fit-results-display-naming.md create mode 100644 docs/dev/benchmarking/20260525-231754_darwin-arm64_py314_tutorial-benchmarks.csv delete mode 100644 docs/dev/plans/emcee-minimizer.md delete mode 100644 docs/dev/plans/minimizer-input-output-split.md create mode 100644 docs/docs/tutorials/ed-25.ipynb create mode 100644 docs/docs/tutorials/ed-25.py create mode 100644 docs/docs/tutorials/ed-26.ipynb create mode 100644 docs/docs/tutorials/ed-26.py create mode 100644 src/easydiffraction/analysis/categories/minimizer/emcee.py create mode 100644 src/easydiffraction/analysis/fit_helpers/_diagnostics.py create mode 100644 src/easydiffraction/analysis/minimizers/emcee.py create mode 100644 src/easydiffraction/analysis/minimizers/emcee_defaults.py create mode 100644 tests/integration/fitting/test_emcee.py create mode 100644 tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py create mode 100644 tests/unit/easydiffraction/analysis/fit_helpers/test__diagnostics.py create mode 100644 tests/unit/easydiffraction/analysis/minimizers/test_emcee.py create mode 100644 tests/unit/easydiffraction/analysis/minimizers/test_emcee_defaults.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 1bf989d5c..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,253 +0,0 @@ -# Copilot Instructions for EasyDiffraction - -## Project Context - -- Python library for crystallographic diffraction analysis (refining - structural models against experimental data). -- Domain axes: `sample_form` (powder, single crystal), `beam_mode` - (time-of-flight, constant wavelength), `radiation_probe` (neutron, - x-ray), `scattering_type` (bragg, total). -- Calculation backends: `cryspy` and `crysfml` (Bragg), `pdffit2` (total - scattering). -- CIF maps to `DatablockItem`/`DatablockCollection` and - `CategoryItem`/`CategoryCollection` (loops). Follow CIF naming; - deviate only for a clearly better API. -- Metadata via frozen dataclasses: `TypeInfo`, `Compatibility`, - `CalculatorSupport`. -- Audience is scientists, often non-programmers: prioritize - discoverability, clear errors, and safe defaults over developer - ergonomics. -- Critical-software rigor: every code path tested, edge cases handled - explicitly, no silent failures. - -## Code Style - -- snake_case (functions/vars), PascalCase (classes), UPPER_SNAKE_CASE - (constants). -- `from __future__ import annotations` in every module. Type-annotate - all public signatures. -- Numpy-style docstrings on all public classes/methods (Parameters / - Returns / Raises where applicable). Summary is one line ≤72 chars - (`max-doc-length`); shorten wording rather than wrap. -- Flat over nested, explicit over clever, composition over deep - inheritance. No defensive checks for unlikely edge cases. -- One class per file when substantial; group small related classes. -- No `**kwargs` — use explicit keyword arguments. -- No string-based dispatch (e.g. `getattr(self, f'_{name}')`); write - named methods (`_set_sample_form`, `_set_beam_mode`). Narrow framework - metadata lookups are allowed when the attribute name is a class-level - declaration, is not user input, and is validated in one central place; - for example, `CategoryItem._category_entry_name`. -- Public attrs are either editable (getter+setter property) or read-only - (getter only). For internal mutation of read-only props, use a private - `_set_` method, not a public setter. -- Lint complexity thresholds in `pyproject.toml` (`max-args`, - `max-branches`, `max-statements`, `max-locals`, `max-nested-blocks`, - …) are guardrails. A violation means refactor (extract helpers, - parameter objects, flatten) — do not raise thresholds, add `# noqa`, - or otherwise silence them. For complex refactors touching many lines - or public API, propose a plan and wait for approval. - -## Architecture - -- Eager top-of-module imports by default. Lazy imports only to break - circular deps or to keep `core/` free of heavy imports on rarely- - called paths (e.g. `help()`). -- No `pkgutil`/`importlib` auto-discovery, no background threads, no - monkey-patching or runtime class mutation. -- No `__all__`; control public API via explicit `__init__.py` imports. - No redundant `import X as X` aliases. -- Concrete classes use `@Factory.register`. Each package's `__init__.py` - must explicitly import every concrete class to trigger registration — - always update it when adding a class. -- Switchable categories (factory-swappable at runtime) follow this fixed - API on the owner (experiment / structure / analysis): `` - (read-only), `_type` (getter+setter), - `show_supported__types()`, `show_current__type()`. - The owner owns the type setter and show methods; show methods delegate - to `Factory.show_supported(...)`. Required even if only one - implementation exists. -- Categories are flat siblings within their owner. Never nest a category - as a child of another category of a different type; cross-reference - via IDs instead. -- Every finite, closed set of values (factory tags, axes, enumerated - descriptors) is a `(str, Enum)`; compare against members, not raw - strings. -- Keep `core/` free of domain logic (base classes and utilities only). -- Don't introduce abstractions before a concrete second use case. Don't - add dependencies without asking. - -## Testing - -- Every new module, class, or bug fix ships with tests. See - `docs/dev/adrs/accepted/test-strategy.md` for the full strategy. -- Unit tests mirror the source tree: - `src/easydiffraction//.py` → - `tests/unit/easydiffraction//test_.py`. Verify with - `pixi run test-structure-check`. Supplementary tests: - `test__coverage.py`. Category packages with only - `default.py`/`factory.py` may use one parent-level - `test_.py`. -- Tests expecting `log.error()` to raise must `monkeypatch` Logger to - RAISE mode (another test may have leaked WARN mode). -- `@typechecked` setters raise `typeguard.TypeCheckError`, not - `TypeError`. -- No test-ordering dependence, no network, no sleeping, no real - calculation engines in unit tests. - -## Tutorials - -- Notebooks in `docs/docs/tutorials/*.ipynb` are generated artifacts. - Edit only the corresponding `*.py`, then run - `pixi run notebook-prepare`. - -## Change Discipline - -- Before any structural/design change (new categories, factories, - switchable-category wiring, datablocks, CIF serialisation), read - `docs/dev/adrs/index.md` and the relevant accepted ADRs. Localised bug - fixes or test updates need only this file. -- Development documentation lives under `docs/dev/`. Use - `docs/dev/adrs/index.md` as the architecture and decision navigation - surface; there is no separate `architecture.md` source of truth. -- Project is in beta: no legacy shims, no deprecation warnings — update - tests and tutorials to the current API. -- Minimal diffs; don't reformat working code. Fix only what's asked; - flag adjacent issues as comments. Don't add features or refactor - unless asked. Don't remove TODOs or comments unless the change fully - resolves them. -- Never remove or replace existing functionality without explicit - confirmation — highlight every removal and wait for approval. -- When renaming or auditing usages, search the entire project (code, - tests, tutorials, docs). Use `git grep -n` because all contributors - have Git; do not assume `rg` is installed. If `git grep` is - unavailable, fall back to `find ... -type f` plus `grep -n`. -- When asked to review a plan, save the review next to that plan using - `_review-N.md`, where `N` is one greater than the highest - existing review number for that plan. For example, - `docs/dev/plans/background-refactor.md` is reviewed in - `docs/dev/plans/background-refactor_review-1.md`, then - `docs/dev/plans/background-refactor_review-2.md`. A reviewer must not - run tests, `pixi run fix`, `pixi run check`, or any other build or - verification command; reviews are static reads of code, plan, and - documentation only. Note in the review which checks were skipped so - the next implementer knows the gap. -- Writing a review or a reply to a review does **not** require running - any formatter (`prettier`, `pixi run fix`, `ruff format`, …) or any - lint/check/test command on the review/reply file itself or any - surrounding documentation. Review and reply files are markdown-only, - written by hand, and committed as-is. Formatting passes happen later, - during implementation Phase 2 verification — not in the review cycle. - This rule applies to both `_review-N.md` and `_reply-N.md` files - regardless of where they live (`docs/dev/plans/`, `docs/dev/adrs/…/`, - etc.). -- Each change is atomic and single-commit-sized: make one change, - suggest the commit message, then stop and wait for confirmation. -- When in doubt, ask. - -## Commits - -- Suggest a commit message after each change: code block, ≤72 chars, - imperative mood, no type prefix, no `Co-authored-by: Copilot`. - Examples: - - Add ChebyshevPolynomialBackground class - - Implement background_type setter on Experiment - - Standardize switchable-category naming convention -- Stage only the files modified for the step, using explicit paths where - practical. Do not include data, project, CIF, or other generated - artifacts produced by integration/script/notebook tests unless the - user explicitly asked to update them. -- Before each commit, inspect the worktree and avoid staging unrelated - user changes. If unrelated dirty files exist, leave them untouched and - mention them only when relevant. - -## Workflow - -Non-trivial changes use a two-phase workflow: - -- **Phase 1 — Implementation.** Code and docs updates only. Update ADRs - when the change affects architecture or documented decisions. Do not - create or run tests unless the user explicitly asks. When done, - present for review and iterate until approved. -- **Phase 2 — Verification.** Add/update tests, then run `pixi run fix`, - `pixi run check`, `pixi run unit-tests`, `pixi run integration-tests`, - `pixi run script-tests`. - -Notes: - -- `pixi run fix` regenerates `docs/dev/package-structure/full.md` and - `docs/dev/package-structure/short.md` automatically — never edit those - by hand. Don't review auto-fixes; accept and move on. Then - `pixi run check` until clean. -- When a check command needs saved output for analysis, capture the log - and preserve the command exit code with a zsh-safe variable name: - `pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code`. - Never assign to `status` in zsh; it is readonly. Use task-specific - names such as `check_exit_code`, `unit_tests_exit_code`, or - `script_tests_exit_code`. -- Open issues / design questions / planned improvements live in - `docs/dev/issues/open.md` (priority-ordered). On resolution, move to - `docs/dev/issues/closed.md` and update the relevant ADR or - `docs/dev/adrs/index.md` if affected. - -### Planning - -When asked to create a plan: - -- Start the plan by referencing this file: - `.github/copilot-instructions.md`. State any deliberate exception to - these instructions in the plan itself. -- First gather enough repository context to make the plan concrete. Ask - all ambiguous or unclear questions in one concise batch; record - unresolved questions in the plan if the user wants it saved before - answering them. -- Save plans as `docs/dev/plans/.md` (lowercase, - dash-separated, e.g. `docs/dev/plans/background-refactor.md`). When a - plan implements one ADR, use the same slug as the ADR file; for - example, `docs/dev/adrs/suggestions/foo.md` maps to - `docs/dev/plans/foo.md`. If a plan has no corresponding ADR or spans - multiple ADRs, choose a concise feature slug and list all related ADRs - in the plan. Use the same `` for the implementation - branch (`feature/`). Do not push the branch unless - asked. -- Include a status checklist with `[ ]` items; mark `[x]` as completed - during implementation. -- Apply the two-phase workflow (Phase 1 implementation, Phase 2 - verification) to non-trivial plans. Stop after Phase 1 and ask the - user to review before starting Phase 2. -- The plan must explicitly state that, when an AI agent follows it, - every completed Phase 1 implementation step must be staged with - explicit paths and committed locally before moving to the next - implementation step or the Phase 1 review gate. Follow the rules in - **Commits**. Keep commits atomic, single-purpose, and aligned with - plan steps. -- If implementation uncovers a serious requirement, risk, design issue, - or scope change not covered by the plan, stop and ask the user for - clarification or approval before proceeding. Record the unresolved - issue in the plan when useful. -- The plan should be easy to maintain while working: include concrete - files likely to change, decisions already made, open questions, - verification commands for Phase 2, and a short suggested commit - message or branch name when useful. -- Verification commands in plans must include the zsh-safe log-capture - pattern from **Workflow** whenever saved output is needed for later - analysis. -- Before saving a plan, verify that referenced files, directories, - scripts, and task names exist locally when that is practical. If a - referenced tool is optional or missing, include an available fallback. -- End every plan with a "Suggested Pull Request" section containing a - short PR title and a brief end-user-oriented description. Keep this - section non-technical enough for scientists and other users to - understand the benefit. Update it during implementation if extra - approved changes become important enough to mention in the PR title or - description. -- When replying to a plan review, save the reply alongside the review. - Reviews live at `docs/dev/plans/_review-.md`; the - matching reply goes to `docs/dev/plans/_reply-.md` - (same slug, same number, swap `review` → `reply`). One reply file per - review file; do not bundle replies to multiple reviews into one - document. Structure the reply with one section per finding, each - containing a verdict (agree / disagree / partial), the action taken in - the plan, and a pointer to the affected plan section. After updating - the plan, also update the reply if a numbered step shifts so that - cross-references stay accurate. diff --git a/.gitignore b/.gitignore index ec2e0d270..cb7130005 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,10 @@ CMakeLists.txt.user* *.log *.zip +# Agents +AGENTS.md +CLAUDE.md + # ED # Used to fetch tutorials data during their runtime. Need to have '/' at # the beginning to avoid ignoring 'data' module in the src/. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 60be90eb9..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Agent Instructions - -Follow -[`.github/copilot-instructions.md`](.github/copilot-instructions.md) for -this repository. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b21d16db8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@.github/copilot-instructions.md diff --git a/docs/dev/adrs/accepted/analysis-cif-fit-state.md b/docs/dev/adrs/accepted/analysis-cif-fit-state.md index 0cc5e658a..8b31da419 100644 --- a/docs/dev/adrs/accepted/analysis-cif-fit-state.md +++ b/docs/dev/adrs/accepted/analysis-cif-fit-state.md @@ -63,9 +63,17 @@ pre-fit scalar snapshots: - `param_unique_name` - `fit_min` - `fit_max` -- `fit_bounds_uncertainty_multiplier` - `start_value` - `start_uncertainty` + +When any row has uncertainty-derived bounds, `_fit_parameter` also +stores the provenance field: + +- `fit_bounds_uncertainty_multiplier` + +For Bayesian fit projections, `_fit_parameter` also stores per-parameter +posterior summaries: + - `posterior_best_sample_value` - `posterior_median` - `posterior_uncertainty` @@ -104,6 +112,10 @@ Deterministic fit-result classes add compact fit output counts: - `degrees_of_freedom` - `covariance_available` - `correlation_available` + +When the LSQ backend provides a termination reason that differs from the +common `_fit_result.message`, deterministic fit results also store: + - `exit_reason` Do not persist a `_deterministic_parameter_result` category. Final @@ -127,11 +139,16 @@ Bayesian fit-result classes store scalar outputs under `_fit_result.*`: - `sampler_completed` - `credible_interval_inner` - `credible_interval_outer` -- `acceptance_rate_mean` +- `resolved_random_seed` - `gelman_rubin_max` - `effective_sample_size_min` - `best_log_posterior` +When the backend reports an acceptance rate, Bayesian fit results also +store: + +- `acceptance_rate_mean` + Bayesian per-parameter posterior summaries are stored on the corresponding `_fit_parameter` rows. Their row order defines the saved posterior parameter order. diff --git a/docs/dev/adrs/accepted/fit-results-display-naming.md b/docs/dev/adrs/accepted/fit-results-display-naming.md new file mode 100644 index 000000000..5704102e1 --- /dev/null +++ b/docs/dev/adrs/accepted/fit-results-display-naming.md @@ -0,0 +1,336 @@ +# ADR: Fit Results Display Naming Convention + +## Status + +Accepted. + +## Date + +2026-05-25 + +## Group + +User-facing API. + +## Context + +`project.display.fit.results()` and `project.display.posterior.*` (see +[`display-ux.md`](display-ux.md)) currently emit fit-result tables with +inconsistent and sometimes long column headers across the two fitting +modes: + +- **LSQ:** `📈 Fitted parameters:` with columns + `start | fitted | uncertainty | change`. +- **Bayesian:** two tables. + - `📈 Committed parameters:` with columns + `start | best posterior sample | uncertainty | change`. + - `📊 Posterior parameter summaries:` with columns + `median | 95% interval | r-hat | ess bulk`. + +Three problems: + +1. `best posterior sample` (21 chars) is too wide for HTML / markdown + layouts and forces the other columns into narrow space. +2. `uncertainty` is the column header in both LSQ and Bayesian committed + tables but the underlying quantities differ (covariance-derived σ vs + posterior SD). The display layer does not annotate the difference. +3. LSQ's `fitted` and Bayesian's `best posterior sample` are + conceptually parallel (the value committed back to the project) but + the headers do not signal that parallelism, complicating side-by-side + reading. + +Two conventions guide the cross-method naming choice: + +- **IUCr CIF** prefers the `_su` suffix (standard uncertainty); `_esd` + (estimated standard deviation) is deprecated. +- **GUM** (Guide to the Expression of Uncertainty in Measurement) treats + Bayesian posterior SD and frequentist standard uncertainty as the same + physical quantity — 1σ of the inferred distribution of the measurand. + +Both converge on `s.u.` as the appropriate cross-method label. + +[`display-ux.md`](display-ux.md) defines facade method names but not +column headers or footnotes; +[`iucr-cif-tag-alignment.md`](../suggestions/iucr-cif-tag-alignment.md) +defines persisted CIF tag names but not display labels; +[`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) defines Python +and CIF attribute names but not user-visible labels. Display naming for +fit-results tables is a real gap. + +## Decision + +### 1. Short headers paired with a footnote glossary + +Every fit-results table emits a glossary block immediately below the +table that expands the short column headers into one-line descriptions. +The footnote disambiguates per fitting mode so the column header itself +can stay short. + +### 2. Cross-method consistency where the physical quantity is the same + +Same column header where the underlying physical quantity matches: + +- `start` — initial parameter value, both modes. +- `value` — refined / committed value, both modes. +- `s.u.` — 1σ standard uncertainty, both modes (covariance for LSQ, + posterior SD for Bayesian; same physical meaning per GUM). +- `change` — `value − start`, both modes. + +Different headers only for Bayesian-only quantities (no LSQ analogue): +`median`, `95% CI`, `r-hat`, `ess bulk`. + +### 3. Canonical column layouts and titles + +**LSQ — `📈 Refined parameters:`** + +``` +| datablock | category | entry | parameter | units | start | value | s.u. | change | +``` + +Footnote: + +``` +start = parameter value before refinement +value = refined value from least-squares minimization +s.u. = standard uncertainty (1σ), from the covariance matrix +change = relative change from start, in %; ↑ = increase, ↓ = decrease +``` + +**Bayesian — `📈 Committed parameters:`** (title unchanged) + +``` +| datablock | category | entry | parameter | units | start | value | s.u. | change | +``` + +Footnote: + +``` +start = parameter value before sampling +value = estimate written back to the project (best posterior sample) +s.u. = standard uncertainty (1σ), the posterior standard deviation +change = relative change from start, in %; ↑ = increase, ↓ = decrease +``` + +**Bayesian — `📊 Posterior distribution:`** + +``` +| datablock | category | entry | parameter | units | median | 95% CI | r-hat | ess bulk | +``` + +Footnote: + +``` +median = 50th percentile of the marginal posterior +95% CI = 95% credible interval (2.5%–97.5%, asymmetric) +r-hat = Gelman–Rubin diagnostic (good convergence: r-hat ≤ 1.01) +ess bulk = bulk effective sample size (typically ≥ 400) +``` + +### 4. Title changes from the current implementation + +- `📈 Fitted parameters:` → `📈 Refined parameters:` (IUCr-style + "refinement" wording, also matches the cross-method `value` column). +- `📈 Committed parameters:` stays unchanged — the duality of "committed + values" vs "posterior distribution" is meaningful and worth preserving + on the Bayesian side. +- `📊 Posterior parameter summaries:` → `📊 Posterior distribution:` + (shorter and explicit about what the second table shows). + +### 5. Chart legend convention + +Chart legends use the full footnote-form name where the chart has +horizontal space. Where the plot title already signals context (e.g. +"Posterior distribution of "), legends may shorten to the +table-header form: + +- Posterior distribution plots: `estimate`, `median`, + `95% credible interval`. +- Measured-vs-calculated plots: `measured`, `calculated`. + +Existing chart legends that describe plot **type** (e.g. +`Marginal density`, `Posterior contours`, `Posterior samples`) are not +parameter-value labels and are out of scope for this ADR. + +### 6. Internal attribute names unchanged + +`Parameter.value`, `Parameter.uncertainty`, +`Parameter.posterior_uncertainty`, and every persisted CIF tag stay as +they are. This ADR governs **display strings only**, not the Python or +CIF API. + +## Addendum (2026-05-25): Fit-results table replaces emoji-line summary + +The original ADR specified two parameter-level tables for Bayesian fits +(`Committed parameters`, `Posterior distribution`) and one for LSQ +(`Refined parameters`), each below an emoji-line summary block +(`✅ Success: True`, `📏 Goodness-of-fit (reduced χ²): 1.29`, …). In +practice the emoji-line block grew long, mixed multi-value lines +(`📊 Convergence: status=passed, max_r_hat=1.004, …`) with single-value +lines (`📏 R-factor (Rf): 5.65%`), and split related information across +visually-different formats. + +The block is now rendered as **one additional 2-column table** per fit +method, sitting directly above the parameter tables: + +- LSQ: `📋 Least-squares fit results:` — title. +- Bayesian: `📋 Bayesian fit results:` — title. + +Column layout: `Metric | Value`, left/right alignment. Each row carries +one emoji-prefixed metric name in the first column and one scalar value +in the second. The previous `console.paragraph('Fit results')` / +`console.paragraph('Bayesian fit results')` section header is dropped — +the table title now signals the section. + +Canonical row order (top-to-bottom): + +1. `🧪 Minimizer` / `🧪 Sampler` — the minimizer.type string (e.g. + `lmfit (leastsq)`, `bumps (dream)`). +2. `✅ Overall status` — single shared value vocabulary: `success` / + `failed`. For LSQ this mirrors `FitResults.success`. For Bayesian + this is `success` only when the sampler completed _and_ convergence + passed, else `failed`. Per-metric convergence detail goes in rows + 12–16 below. +3. `💬 Engine message` _(Bayesian, optional)_ — the engine's free-form + status message, e.g. `DREAM sampling completed`. +4. `⏱️ Fitting time (seconds)` — `fitting_time`. +5. `🔁 Iterations` _(LSQ, optional)_ — shown only when + `FitResults.iterations > 0`. +6. `📏 Goodness-of-fit (reduced χ²)` — `reduced_chi_square`. 7–10. + `📏 R-factor (Rf, %)`, `📏 R-factor squared (Rf², %)`, + `📏 Weighted R-factor (wR, %)`, `📏 Bragg R-factor (BR, %)` — each + row when the corresponding inputs are available. Units appear in the + metric name, so the value cell holds a bare number. (R-factors come + immediately after goodness-of-fit and before `Best log-posterior` — + both methods agree on this order.) +7. `📉 Best log-posterior` _(Bayesian, optional)_ — shown when + `best_log_posterior is not None`. 12–16. _(Bayesian only)_ + Convergence rows derived from `convergence_diagnostics`: - + `📊 Convergence status` — `passed` / `failed`. - `📊 Max r-hat` — + formatted to 3 decimals. - `📊 Min ess bulk` — formatted to 1 + decimal. - `📊 Draws per chain`. - `📊 Chains`. + +The shared-vocabulary `success` / `failed` for `Overall status` is +intentional cross-method consistency: a reader scanning LSQ and Bayesian +outputs side-by-side sees the same status word in the same row position +regardless of method. Bayesian-specific nuance (sampler completed but +convergence flagged, etc.) is exposed in the convergence rows below. + +**Rows dropped relative to the previous emoji-line summary:** + +- `🎯 Committed point estimate: Best posterior sample` — already + documented by the `Committed parameters` table footnote + (`value = estimate written back to the project (best posterior sample)`). +- `🔁 Sampler completed: yes` — redundant with `Overall status`. +- `⚙️ Sampler settings: steps=…, burn=…, …` — already in the + `Settings used` table above the fit-results table. +- The derived `samples = n_draws × n_chains` count — derived from the + `Draws per chain` and `Chains` rows immediately below. + +**Table-title icons.** The four fit-output tables now carry a +distinguishing icon in their title so the four blocks are visually +separable when scrolling: + +| Table | Title prefix | +| --------------------------------- | ------------------------------------------------------------ | +| Minimizer settings | `⚙️ Settings used:` | +| Fit-method summary | `📋 Least-squares fit results:` / `📋 Bayesian fit results:` | +| Committed values | `📈 Refined parameters:` / `📈 Committed parameters:` | +| Posterior summary (Bayesian only) | `📊 Posterior distribution:` | + +The icons are also the same emoji used inside the rows of the +corresponding fit-results-summary table (📏 for goodness-of-fit metrics, +📊 for convergence diagnostics), so the visual language is internally +consistent. + +**Internal-implementation note.** Helper `print_metrics_table(rows)` in +`easydiffraction.utils.utils` renders the new 2-column table from a list +of `[label, value]` rows. Both `reporting.FitResults.display_results()` +and `bayesian.BayesianFitResults.display_results()` build their rows via +a `_build_fit_results_rows()` instance method and feed +`print_metrics_table()`. The shared signature keeps the two methods +structurally parallel. + +## Consequences + +### Positive + +- Tables fit standard HTML / markdown width without truncating the + formerly 21-character `best posterior sample` column. +- Users can compare LSQ and Bayesian results column-by-column (`start`, + `value`, `s.u.`, `change` line up identically). +- IUCr / GUM-aligned terminology. +- The inline footnote glossary gives non-programmer users a + discoverability path without having to leave the table to read + external docs. +- Setting the convention in an ADR keeps future fit-result tables (a new + sampler, an alternative refinement strategy) on the same naming. + +### Trade-offs + +- Existing tutorials, tests, and integration outputs that pin the + literal strings `Fitted parameters`, `Posterior parameter summaries`, + `fitted`, `best posterior sample`, `uncertainty`, `95% interval` need + updating in the implementation PR. +- `s.u.` is unfamiliar to readers who do not know GUM or IUCr CIF. The + footnote covers this; the compactness win at the column header is the + main argument. + +### ADRs related to this ADR + +None directly amended. This ADR complements: + +- [`display-ux.md`](display-ux.md) — defines facade method names; this + ADR fills in the column-header layer underneath. +- [`iucr-cif-tag-alignment.md`](../suggestions/iucr-cif-tag-alignment.md) + — defines persisted CIF tag names; this ADR is the matching + display-time label layer. +- [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) — defines + Python / CIF attribute names (e.g. `Parameter.uncertainty`, + `posterior_uncertainty`); display headers map to those without + renaming them. + +## Alternatives Considered + +### A. Keep `uncertainty` as the column header for both modes + +Pros: zero changes. Cons: ambiguous in Bayesian context (users may +confuse it with the 95% credible interval below); inconsistent with the +IUCr CIF `_su` convention; reinforces the wider `best posterior sample` +problem because it does not solve the layout issue. + +### B. `posterior SD` for Bayesian, `uncertainty` for LSQ + +Pros: explicit on the Bayesian side. Cons: different column headers for +the same physical quantity (1σ width), breaking the column-by-column +comparison; longer (10 chars vs 4 for `s.u.`). + +### C. Different headers for the committed-value column (`refined` vs + +`estimate` vs `value`) + +Three different headers for "the value committed to the project". Pros: +each method-accurate. Cons: breaks the cross-method consistency goal; +readers seeing `refined` next to `estimate` in side-by-side tables +wonder what the semantic difference is even though the underlying +quantity is the same. Decision: use neutral `value` everywhere, let the +footnote disambiguate. + +### D. Single Bayesian table covering both committed values and + +posterior summary + +Pros: one table to read. Cons: nine value columns plus identity columns +exceed standard HTML width and truncate. The two-table split is forced +by layout and meaningfully preserves the "what did I commit" vs "what +does the posterior look like" duality. + +## Deferred Work + +- The `acceptance rate` column in the posterior distribution table. Not + displayed by default today; a future ADR can decide whether it joins + the canonical layout or stays in a verbose mode. +- Inline footnote text vs Markdown link to a docs-site glossary. Inline + is the initial form; promotion to a glossary page is a future ADR if + footnote lengths grow. +- Localisation. All display strings are English; non-English UIs are out + of scope. diff --git a/docs/dev/adrs/accepted/minimizer-category-consolidation.md b/docs/dev/adrs/accepted/minimizer-category-consolidation.md index ed1e24f36..f7a1cdb30 100644 --- a/docs/dev/adrs/accepted/minimizer-category-consolidation.md +++ b/docs/dev/adrs/accepted/minimizer-category-consolidation.md @@ -231,6 +231,19 @@ Verbose CIF tags are user-facing. The canonical MCMC abbreviation field so it appears in `help()` output but does not become a Python attribute or a CIF tag. +**Implementation note (2026-05-25).** The per-parameter R̂ and bulk ESS +values feeding `gelman_rubin_max` and `effective_sample_size_min` are +computed by an in-tree helper at +[`analysis/fit_helpers/_diagnostics.py`](../../../../src/easydiffraction/analysis/fit_helpers/_diagnostics.py) +— pure NumPy + SciPy implementations of split R̂ and rank-normalized bulk +ESS (Vehtari, Gelman, Simpson, Carpenter and Bürkner 2019; Geyer 1992). +The earlier `arviz` dependency, which the library only used to call +`az.rhat()` and `az.ess(method='bulk')`, has been removed; the +diagnostics' public surface (`gelman_rubin_max`, +`effective_sample_size_min`, `r_hat_by_parameter`, +`ess_bulk_by_parameter`) and the convergence thresholds (R̂ ≤ 1.01, ESS +≥ 400) are unchanged. + ### 6. Unified `initialization_method` enum A single `(str, Enum)` `InitializationMethodEnum` with members: @@ -280,7 +293,7 @@ differs. class EmceeMinimizer(BayesianMinimizerBase): _default_sampling_steps = 5000 _default_population_size = 32 - _default_proposal_moves = 'stretch' + _default_proposal_moves = 'de' ``` When `analysis.minimizer_type` changes, the underlying instance is @@ -291,6 +304,10 @@ swap warnings. ### 9. Example CIF layouts +The fit-result outputs in these examples live under `_fit_result.*` per +[`minimizer-input-output-split.md`](minimizer-input-output-split.md); +`_minimizer.*` carries only user-writable settings. + `bumps (lm)`: ``` @@ -300,10 +317,12 @@ _fitting.mode_type joint _fitting.minimizer_type 'bumps (lm)' _minimizer.max_iterations 200 -_minimizer.runtime_seconds 12.34 -_minimizer.iterations_performed 87 -_minimizer.exit_reason converged -_minimizer.reduced_chi2 1.42 + +_fit_result.result_kind deterministic +_fit_result.fitting_time 12.34 +_fit_result.iterations 87 +_fit_result.exit_reason converged +_fit_result.reduced_chi_square 1.42 ``` `bumps (dream)`: @@ -321,12 +340,14 @@ _minimizer.population_size 4 _minimizer.parallel_workers 0 _minimizer.initialization_method latin_hypercube _minimizer.random_seed ? -_minimizer.runtime_seconds 124.7 -_minimizer.acceptance_rate_mean 0.27 -_minimizer.gelman_rubin_max 1.03 -_minimizer.effective_sample_size_min 482 -_minimizer.best_log_posterior -1234.56 -_minimizer.reduced_chi2 1.18 + +_fit_result.result_kind bayesian +_fit_result.fitting_time 124.7 +_fit_result.reduced_chi_square 1.18 +_fit_result.acceptance_rate_mean 0.27 +_fit_result.gelman_rubin_max 1.03 +_fit_result.effective_sample_size_min 482 +_fit_result.best_log_posterior -1234.56 ``` `emcee` (added by the follow-up plan): @@ -339,18 +360,20 @@ _fitting.minimizer_type emcee _minimizer.sampling_steps 5000 _minimizer.burn_in_steps 1000 -_minimizer.thinning_interval 5 +_minimizer.thinning_interval 1 _minimizer.population_size 32 -_minimizer.proposal_moves stretch +_minimizer.proposal_moves de _minimizer.parallel_workers 0 _minimizer.initialization_method ball _minimizer.random_seed 42 -_minimizer.runtime_seconds 87.3 -_minimizer.acceptance_rate_mean 0.31 -_minimizer.gelman_rubin_max 1.02 -_minimizer.effective_sample_size_min 612 -_minimizer.best_log_posterior -1237.89 -_minimizer.reduced_chi2 1.22 + +_fit_result.result_kind bayesian +_fit_result.fitting_time 87.3 +_fit_result.reduced_chi_square 1.22 +_fit_result.acceptance_rate_mean 0.31 +_fit_result.gelman_rubin_max 1.02 +_fit_result.effective_sample_size_min 612 +_fit_result.best_log_posterior -1237.89 ``` emcee's resumable chain state lives in the `/emcee_chain` group of the @@ -403,10 +426,9 @@ category's class-level `_engine_metadata` dict. - Hand-editing CIF to switch minimizer types requires touching both `_fitting.minimizer_type` and the relevant `_minimizer.*` tags. - Existing projects saved under the seven-category layout cannot load - unchanged. The project is in beta; per - `.github/copilot-instructions.md` "no legacy shims" applies. Saved - fixtures under `tmp/tutorials/projects/` are regenerated by the - implementation plan. + unchanged. The project is in beta; per `AGENTS.md` "no legacy shims" + applies. Saved fixtures under `tmp/tutorials/projects/` are + regenerated by the implementation plan. ### ADRs amended by this ADR diff --git a/docs/dev/adrs/accepted/minimizer-input-output-split.md b/docs/dev/adrs/accepted/minimizer-input-output-split.md index 13a33a5be..ee2656fc1 100644 --- a/docs/dev/adrs/accepted/minimizer-input-output-split.md +++ b/docs/dev/adrs/accepted/minimizer-input-output-split.md @@ -159,7 +159,7 @@ live on `FitResultBase`; family-specific fields on the concrete classes: `exit_reason`. - `BayesianFitResult` adds: `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, `credible_interval_outer`, - `acceptance_rate_mean`, `gelman_rubin_max`, + `resolved_random_seed`, `acceptance_rate_mean`, `gelman_rubin_max`, `effective_sample_size_min`, `best_log_posterior`. The three overlapping pairs from §"Context" are resolved by **dropping diff --git a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md index f74fdf849..9207992e5 100644 --- a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md +++ b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md @@ -564,10 +564,9 @@ categories and their swap hooks at the start of Phase 1. ### 7. Beta posture: hard cutover, no shims -[`.github/copilot-instructions.md`](../../../../.github/copilot-instructions.md) -→ **Change Discipline**: "Project is in beta: no legacy shims, no -deprecation warnings — update tests and tutorials to the current API." -This ADR keeps that posture: +[`AGENTS.md`](../../../../AGENTS.md) → **Change Discipline**: "Project +is in beta: no legacy shims, no deprecation warnings — update tests and +tutorials to the current API." This ADR keeps that posture: - `._type` is **deleted**, not deprecated. - `show_supported__types()` / `show_current__type()` are @@ -872,10 +871,9 @@ full grep results.) Replace `self.__class__` with the new concrete class so the user's reference keeps pointing at "the same object". Rejected: -[`.github/copilot-instructions.md`](../../../../.github/copilot-instructions.md) -→ **Architecture** forbids it ("no monkey-patching or runtime class -mutation"). It would also confuse `isinstance` checks and break -descriptor introspection. +[`AGENTS.md`](../../../../AGENTS.md) → **Architecture** forbids it ("no +monkey-patching or runtime class mutation"). It would also confuse +`isinstance` checks and break descriptor introspection. ### B. Single category class with internal mode switching @@ -983,16 +981,21 @@ _minimizer.population_size 4 _minimizer.parallel_workers 0 _minimizer.initialization_method latin_hypercube _minimizer.random_seed ? -_minimizer.runtime_seconds 124.7 -_minimizer.acceptance_rate_mean 0.27 -_minimizer.gelman_rubin_max 1.03 -_minimizer.effective_sample_size_min 482 -_minimizer.best_log_posterior -1234.56 -_minimizer.reduced_chi2 1.18 + +_fit_result.result_kind bayesian +_fit_result.fitting_time 124.7 +_fit_result.reduced_chi_square 1.18 +_fit_result.acceptance_rate_mean 0.27 +_fit_result.gelman_rubin_max 1.03 +_fit_result.effective_sample_size_min 482 +_fit_result.best_log_posterior -1234.56 ``` The `_fitting.*` block is gone (`_fitting.minimizer_type` → `_minimizer.type`; `_fitting.mode_type` → `_fitting_mode.type`). +Fit-result outputs live under `_fit_result.*` per +[`minimizer-input-output-split.md`](minimizer-input-output-split.md); +`_minimizer.*` carries only user-writable settings. ### `analysis.cif` (deterministic fit) @@ -1003,11 +1006,13 @@ _fitting_mode.type single _minimizer.type 'lmfit (leastsq)' _minimizer.max_iterations 1000 -_minimizer.objective_value 1532.4 -_minimizer.runtime_seconds 12.34 -_minimizer.iterations_performed 87 -_minimizer.exit_reason converged -_minimizer.reduced_chi2 1.42 + +_fit_result.result_kind deterministic +_fit_result.fitting_time 12.34 +_fit_result.iterations 87 +_fit_result.exit_reason converged +_fit_result.reduced_chi_square 1.42 +_fit_result.objective_value 1532.4 ``` `_minimizer.optimizer_name` and `_minimizer.method_name` are gone — they diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index fcdf7bbfa..c235042db 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -43,6 +43,7 @@ folders. | Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | | Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | | User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | +| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) | | User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | | User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | | User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | diff --git a/docs/dev/benchmarking/20260519-103251_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260519-103251_darwin-arm64_py314_tutorial-benchmarks.csv index edb8e3f63..24cedfe9c 100644 --- a/docs/dev/benchmarking/20260519-103251_darwin-arm64_py314_tutorial-benchmarks.csv +++ b/docs/dev/benchmarking/20260519-103251_darwin-arm64_py314_tutorial-benchmarks.csv @@ -1,24 +1,24 @@ -tutorial_name,elapsed_seconds,status,return_code -ed-1.py,13.789,ok,0 -ed-10.py,39.098,ok,0 -ed-11.py,10.343,ok,0 -ed-12.py,8.331,ok,0 -ed-13.py,21.745,ok,0 -ed-14.py,6.158,ok,0 -ed-15.py,240.151,ok,0 -ed-16.py,58.481,ok,0 -ed-17.py,152.214,ok,0 -ed-18.py,6.114,ok,0 -ed-2.py,18.322,ok,0 -ed-20.py,36.422,ok,0 -ed-21.py,197.610,ok,0 -ed-22.py,194.351,ok,0 -ed-23.py,6.060,ok,0 -ed-24.py,4.749,ok,0 -ed-3.py,19.050,ok,0 -ed-4.py,4.480,ok,0 -ed-5.py,36.605,ok,0 -ed-6.py,61.846,ok,0 -ed-7.py,115.147,ok,0 -ed-8.py,101.442,ok,0 -ed-9.py,9.214,ok,0 +tutorial_name,elapsed_seconds,status +ed-1.py,13.789,ok +ed-2.py,18.322,ok +ed-3.py,19.050,ok +ed-4.py,4.480,ok +ed-5.py,36.605,ok +ed-6.py,61.846,ok +ed-7.py,115.147,ok +ed-8.py,101.442,ok +ed-9.py,9.214,ok +ed-10.py,39.098,ok +ed-11.py,10.343,ok +ed-12.py,8.331,ok +ed-13.py,21.745,ok +ed-14.py,6.158,ok +ed-15.py,240.151,ok +ed-16.py,58.481,ok +ed-17.py,152.214,ok +ed-18.py,6.114,ok +ed-20.py,36.422,ok +ed-21.py,197.610,ok +ed-22.py,194.351,ok +ed-23.py,6.060,ok +ed-24.py,4.749,ok diff --git a/docs/dev/benchmarking/20260519-103500_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260519-103500_darwin-arm64_py314_tutorial-benchmarks.csv index 608944cb5..1658c45aa 100644 --- a/docs/dev/benchmarking/20260519-103500_darwin-arm64_py314_tutorial-benchmarks.csv +++ b/docs/dev/benchmarking/20260519-103500_darwin-arm64_py314_tutorial-benchmarks.csv @@ -1,24 +1,24 @@ -tutorial_name,elapsed_seconds,status,return_code -ed-1.py,13.979,ok,0 -ed-10.py,38.764,ok,0 -ed-11.py,10.606,ok,0 -ed-12.py,9.044,ok,0 -ed-13.py,23.157,ok,0 -ed-14.py,6.585,ok,0 -ed-15.py,258.188,ok,0 -ed-16.py,60.097,ok,0 -ed-17.py,69.418,ok,0 -ed-18.py,6.181,ok,0 -ed-2.py,18.844,ok,0 -ed-20.py,39.157,ok,0 -ed-21.py,96.730,ok,0 -ed-22.py,73.480,ok,0 -ed-23.py,5.984,ok,0 -ed-24.py,4.942,ok,0 -ed-3.py,20.782,ok,0 -ed-4.py,5.780,ok,0 -ed-5.py,37.716,ok,0 -ed-6.py,66.911,ok,0 -ed-7.py,119.645,ok,0 -ed-8.py,103.887,ok,0 -ed-9.py,8.891,ok,0 +tutorial_name,elapsed_seconds,status +ed-1.py,13.979,ok +ed-2.py,18.844,ok +ed-3.py,20.782,ok +ed-4.py,5.780,ok +ed-5.py,37.716,ok +ed-6.py,66.911,ok +ed-7.py,119.645,ok +ed-8.py,103.887,ok +ed-9.py,8.891,ok +ed-10.py,38.764,ok +ed-11.py,10.606,ok +ed-12.py,9.044,ok +ed-13.py,23.157,ok +ed-14.py,6.585,ok +ed-15.py,258.188,ok +ed-16.py,60.097,ok +ed-17.py,69.418,ok +ed-18.py,6.181,ok +ed-20.py,39.157,ok +ed-21.py,96.730,ok +ed-22.py,73.480,ok +ed-23.py,5.984,ok +ed-24.py,4.942,ok diff --git a/docs/dev/benchmarking/20260519-121524_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260519-121524_darwin-arm64_py314_tutorial-benchmarks.csv index ff8eda19a..11101c797 100644 --- a/docs/dev/benchmarking/20260519-121524_darwin-arm64_py314_tutorial-benchmarks.csv +++ b/docs/dev/benchmarking/20260519-121524_darwin-arm64_py314_tutorial-benchmarks.csv @@ -1,24 +1,24 @@ -tutorial_name,elapsed_seconds,status,return_code -ed-1.py,15.557,ok,0 -ed-10.py,40.860,ok,0 -ed-11.py,10.823,ok,0 -ed-12.py,8.861,ok,0 -ed-13.py,24.128,ok,0 -ed-14.py,6.722,ok,0 -ed-15.py,28.243,ok,0 -ed-16.py,59.218,ok,0 -ed-17.py,70.816,ok,0 -ed-18.py,6.944,ok,0 -ed-2.py,20.385,ok,0 -ed-20.py,39.513,ok,0 -ed-21.py,96.953,ok,0 -ed-22.py,75.390,ok,0 -ed-23.py,6.115,ok,0 -ed-24.py,5.159,ok,0 -ed-3.py,34.082,ok,0 -ed-4.py,8.215,ok,0 -ed-5.py,61.949,ok,0 -ed-6.py,83.857,ok,0 -ed-7.py,120.332,ok,0 -ed-8.py,103.831,ok,0 -ed-9.py,9.270,ok,0 +tutorial_name,elapsed_seconds,status +ed-1.py,15.557,ok +ed-2.py,20.385,ok +ed-3.py,34.082,ok +ed-4.py,8.215,ok +ed-5.py,61.949,ok +ed-6.py,83.857,ok +ed-7.py,120.332,ok +ed-8.py,103.831,ok +ed-9.py,9.270,ok +ed-10.py,40.860,ok +ed-11.py,10.823,ok +ed-12.py,8.861,ok +ed-13.py,24.128,ok +ed-14.py,6.722,ok +ed-15.py,28.243,ok +ed-16.py,59.218,ok +ed-17.py,70.816,ok +ed-18.py,6.944,ok +ed-20.py,39.513,ok +ed-21.py,96.953,ok +ed-22.py,75.390,ok +ed-23.py,6.115,ok +ed-24.py,5.159,ok diff --git a/docs/dev/benchmarking/20260525-231754_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260525-231754_darwin-arm64_py314_tutorial-benchmarks.csv new file mode 100644 index 000000000..0f0c8fab0 --- /dev/null +++ b/docs/dev/benchmarking/20260525-231754_darwin-arm64_py314_tutorial-benchmarks.csv @@ -0,0 +1,26 @@ +tutorial_name,elapsed_seconds,status +ed-1.py,20.204,ok +ed-2.py,25.471,ok +ed-3.py,25.688,ok +ed-4.py,5.507,ok +ed-5.py,53.209,ok +ed-6.py,83.848,ok +ed-7.py,155.200,ok +ed-8.py,160.673,ok +ed-9.py,10.812,ok +ed-10.py,48.118,ok +ed-11.py,13.865,ok +ed-12.py,12.846,ok +ed-13.py,33.593,ok +ed-14.py,8.157,ok +ed-15.py,36.489,ok +ed-16.py,77.915,ok +ed-17.py,98.729,ok +ed-18.py,10.579,ok +ed-20.py,49.750,ok +ed-21.py,95.431,ok +ed-22.py,63.560,ok +ed-23.py,28.869,ok +ed-24.py,6.105,ok +ed-25.py,39.325,ok +ed-26.py,37.368,ok diff --git a/docs/dev/issues/closed.md b/docs/dev/issues/closed.md index 4b46b130e..4186146ed 100644 --- a/docs/dev/issues/closed.md +++ b/docs/dev/issues/closed.md @@ -4,6 +4,32 @@ Issues that have been fully resolved. Kept for historical reference. --- +## 103. Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative + +Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). Minimizer +categories now declare `_engine_sync_skip_keys`, and analysis sync +filters against that set instead of hardcoding skipped keys. + +--- + +## 101. Remove Dead Branch in `_fit_state_categories` + +Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). The +deterministic branch that returned the same category list as the +fallthrough path was removed while preserving unsupported `result_kind` +warning behavior. + +--- + +## 100. Collapse Duplicate Predictive-Cache-Key Helpers + +Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). +`posterior_predictive_cache_key()` in `analysis.fit_helpers.bayesian` is +now the single helper used by analysis, plotting, and project display +code. + +--- + ## 77. Add Help Methods to Public Discovery Facades Added consistent `help()` methods for plain user-facing facade classes diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index 1e0bfe340..bf13134ce 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -1668,52 +1668,6 @@ sampler progress displays — any fix should keep their visuals consistent --- -## 100. 🟢 Collapse Duplicate Predictive-Cache-Key Helpers - -**Type:** Refactor / drift risk **Source:** Review 8 finding F1. -**Recommended:** fold into the emcee-minimizer plan while the -surrounding code is being touched. - -`Analysis._predictive_cache_key` -([analysis.py:478-487](../../../src/easydiffraction/analysis/analysis.py)) -and `Plotter._posterior_predictive_key` -([plotting.py:3795-3804](../../../src/easydiffraction/display/plotting.py)) -both return `f'{name}:{x_axis_name}:{suffix}'`. The strings are -identical today; a future refactor that changes one will silently break -lookup against the other. - -**Fix:** collapse to a single helper — either move the canonical helper -to a shared module (e.g. `analysis/fit_helpers/bayesian.py`), or have -`Analysis._store_posterior_predictive_projection` and -`_restored_predictive_summaries` reuse -`Plotter._posterior_predictive_key` from `project.rendering.plotter` -(already accessed nearby). - -**Depends on:** nothing. - ---- - -## 101. 🟢 Remove Dead Branch in `_fit_state_categories` - -**Type:** Dead code **Source:** Review 8 finding F4. **Recommended:** -fold into the emcee-minimizer plan. - -`Analysis._fit_state_categories` -([analysis.py:1135-1148](../../../src/easydiffraction/analysis/analysis.py)) -has -`if result_kind is FitResultKindEnum.DETERMINISTIC: return categories` -followed by `return categories`. Both branches return the same list -since P1.10 absorbed Bayesian-only categories. - -**Fix:** simplify to an unconditional `return categories`. Keep the -preceding `try/except` for its warning side-effect; extract it so the -function body reads cleanly. If a future Bayesian-only category list is -expected, add a TODO instead. - -**Depends on:** nothing. - ---- - ## 102. 🟢 Drop Compute-and-Ignore `result_kind` Validation in CIF Restore **Type:** Dead code / clarity **Source:** Review 8 finding F7. @@ -1735,27 +1689,6 @@ ignore" pattern. --- -## 103. 🟢 Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative - -**Type:** Refactor / discoverability **Source:** Review 8 finding F10. -**Recommended:** fold into the emcee-minimizer plan (it adds -`proposal_moves` which is also engine-level). - -`Analysis._sync_engine_from_minimizer_category` -([analysis.py:1077-1089](../../../src/easydiffraction/analysis/analysis.py)) -hardcodes `if key == 'random_seed': continue` to keep call-time seed -threading via `_resolved_fit_random_seed`. A second ambient key joining -it (emcee `proposal_moves`) will need the same treatment. - -**Fix:** declare -`_engine_sync_skip_keys: ClassVar[frozenset[str]] = frozenset({'random_seed'})` -on `MinimizerCategoryBase` (or per family) and filter against it. Adds -declarative, growing coverage. - -**Depends on:** nothing. - ---- - ## 104. 🟢 Tighten `FitParameterItem.posterior_summary` NaN Behaviour **Type:** Robustness / partial-data edge case **Source:** Review 8 diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index 1f0663352..f39fef485 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -87,6 +87,8 @@ │ │ │ │ └── 🏷️ class BumpsLmMinimizer │ │ │ ├── 📄 dfols.py │ │ │ │ └── 🏷️ class DfolsMinimizer +│ │ │ ├── 📄 emcee.py +│ │ │ │ └── 🏷️ class EmceeMinimizer │ │ │ ├── 📄 factory.py │ │ │ │ └── 🏷️ class MinimizerCategoryFactory │ │ │ ├── 📄 lmfit.py @@ -113,6 +115,7 @@ │ │ └── 📄 __init__.py │ ├── 📁 fit_helpers │ │ ├── 📄 __init__.py +│ │ ├── 📄 _diagnostics.py │ │ ├── 📄 bayesian.py │ │ │ ├── 🏷️ class PosteriorPredictiveSummary │ │ │ ├── 🏷️ class PosteriorSamples @@ -126,6 +129,7 @@ │ ├── 📁 minimizers │ │ ├── 📄 __init__.py │ │ ├── 📄 base.py +│ │ │ ├── 🏷️ class MinimizerFitOptions │ │ │ └── 🏷️ class MinimizerBase │ │ ├── 📄 bumps.py │ │ │ ├── 🏷️ class _BumpsEvaluationLimitError @@ -145,6 +149,12 @@ │ │ │ └── 🏷️ class BumpsLmMinimizer │ │ ├── 📄 dfols.py │ │ │ └── 🏷️ class DfolsMinimizer +│ │ ├── 📄 emcee.py +│ │ │ ├── 🏷️ class _EmceePoolContext +│ │ │ ├── 🏷️ class _EmceeLogProbability +│ │ │ ├── 🏷️ class _EmceeProgressReporter +│ │ │ └── 🏷️ class EmceeMinimizer +│ │ ├── 📄 emcee_defaults.py │ │ ├── 📄 enums.py │ │ │ ├── 🏷️ class MinimizerTypeEnum │ │ │ ├── 🏷️ class InitializationMethodEnum @@ -168,6 +178,7 @@ │ │ ├── 🏷️ class FitResultKindEnum │ │ └── 🏷️ class FitCorrelationSourceEnum │ ├── 📄 fitting.py +│ │ ├── 🏷️ class FitterFitOptions │ │ └── 🏷️ class Fitter │ └── 📄 sequential.py │ ├── 🏷️ class SequentialFitExtractRule @@ -476,7 +487,8 @@ │ ├── 📄 progress.py │ │ ├── 🏷️ class _TerminalLiveHandle │ │ ├── 🏷️ class ActivityIndicator -│ │ └── 🏷️ class _ActivityIndicatorContext +│ │ ├── 🏷️ class _ActivityIndicatorContext +│ │ └── 🏷️ class NotebookFitStopControl │ ├── 📄 tables.py │ │ ├── 🏷️ class TableEngineEnum │ │ ├── 🏷️ class TableRenderer diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index e0a529800..25e3c1acd 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -52,6 +52,7 @@ │ │ │ ├── 📄 bumps_dream.py │ │ │ ├── 📄 bumps_lm.py │ │ │ ├── 📄 dfols.py +│ │ │ ├── 📄 emcee.py │ │ │ ├── 📄 factory.py │ │ │ ├── 📄 lmfit.py │ │ │ ├── 📄 lmfit_least_squares.py @@ -68,6 +69,7 @@ │ │ └── 📄 __init__.py │ ├── 📁 fit_helpers │ │ ├── 📄 __init__.py +│ │ ├── 📄 _diagnostics.py │ │ ├── 📄 bayesian.py │ │ ├── 📄 metrics.py │ │ ├── 📄 reporting.py @@ -81,6 +83,8 @@ │ │ ├── 📄 bumps_dream.py │ │ ├── 📄 bumps_lm.py │ │ ├── 📄 dfols.py +│ │ ├── 📄 emcee.py +│ │ ├── 📄 emcee_defaults.py │ │ ├── 📄 enums.py │ │ ├── 📄 factory.py │ │ ├── 📄 lmfit.py diff --git a/docs/dev/plans/emcee-minimizer.md b/docs/dev/plans/emcee-minimizer.md deleted file mode 100644 index b8f056a2d..000000000 --- a/docs/dev/plans/emcee-minimizer.md +++ /dev/null @@ -1,285 +0,0 @@ -# Plan: Emcee Minimizer - -> This plan follows -> [`.github/copilot-instructions.md`](../../../.github/copilot-instructions.md). -> No deliberate exceptions. - -## Prerequisite - -This plan depends on the -[`minimizer-category-consolidation`](../adrs/accepted/minimizer-category-consolidation.md) -ADR and the -[`switchable-category-owned-selectors`](../adrs/accepted/switchable-category-owned-selectors.md) -ADR. Both are accepted and the implementing work is merged; the -`Analysis.minimizer` + `Analysis.minimizer.type` surface emcee builds on -is in place. - -## ADR - -Implements the emcee follow-on described in §1, §5, §6 and §9 of -[`docs/dev/adrs/accepted/minimizer-category-consolidation.md`](../adrs/accepted/minimizer-category-consolidation.md) -(after the prerequisite plan promotes it). No new ADR is required; this -plan is a direct application of the rules already accepted there. - -If implementation uncovers a design question not covered by the ADR (for -example, resume semantics on parameter-set mismatch), stop and ask -before proceeding. - -## Branch and PR - -- Branch: `feature/emcee-minimizer`. Do not push unless asked. -- Each step in §"Implementation steps (Phase 1)" must be staged with - explicit paths and committed locally **before** moving to the next - step. -- After P1.7, stop and wait for the user review gate before starting - Phase 2. - -## Decisions already made (from the ADR) - -1. emcee is exposed as a new concrete `minimizer` class - (`EmceeMinimizer`) registered under - `MinimizerTypeEnum.EMCEE = 'emcee'`. -2. Sampler settings reuse the verbose attribute names from ADR §5 - (`sampling_steps`, `burn_in_steps`, `thinning_interval`, - `population_size`, `parallel_workers`, `initialization_method`, - `random_seed`) with an emcee-specific addition: `proposal_moves`. -3. Resume uses emcee's `HDFBackend` against the `/emcee_chain` group of - the same `analysis/results.h5` file used by the snapshot writer. No - separate sidecar file. A non-resume `fit()` follows the prerequisite - plan's lifecycle and **truncates** `results.h5` (after the standard - warning); resume opens it in append mode. -4. The `fit()` action accepts an explicit `resume=True, extra_steps=N` - pair when the active minimizer supports incremental sampling. For - other minimizers, passing `resume=True` raises immediately. -5. emcee outputs translate to the existing `BayesianFitResults` shape - exactly as DREAM does — same `PosteriorSamples`, - `PosteriorParameterSummary`, etc. — so plotting and display code - needs no specialization. - -## Open questions - -- **Resume after parameter-set change.** If the user fits, then edits - which parameters are free, then calls `fit(resume=True, ...)`, emcee's - HDFBackend will fail because the dimensionality changed. Plan default: - detect mismatch and raise with a clear message asking the user to - start a fresh run. Confirm during P1.4. -- **Resume after a non-emcee fit.** If the user runs DREAM, then sets - `minimizer_type = 'emcee'`, then calls `fit(resume=True, ...)`, the - `/emcee_chain` group will be missing. Plan default: raise a clear - `ValueError` pointing at the prerequisite-plan lifecycle rule ("a new - fit overwrites the file"). -- **Move-mix semantics.** emcee supports proposal-move mixtures (e.g. 70 - % stretch + 30 % differential evolution). The ADR exposes - `proposal_moves` as a single string. Plan default: limit - `proposal_moves` to single-move strings for v1 (`stretch`, `de`, - `de_snooker`, `walk`). Mixtures deferred to a later plan. Record this - in the descriptor's `description=`. - -## Cleanup opportunities inherited from earlier work - -The consolidation work left four cleanup opportunities tracked in -[`docs/dev/issues/open.md`](../issues/open.md) that touch code this plan -will modify. Fold them in while the surrounding code is already being -edited, rather than queuing a separate refactor PR. - -- **F1 — Collapse duplicate predictive-cache-key helpers.** - `Analysis._predictive_cache_key` and - `Plotter._posterior_predictive_key` build the identical string; keep - one canonical helper. Tracked as [open-issue 100](../issues/open.md). -- **F4 — Drop dead branch in `Analysis._fit_state_categories`.** Both - branches return the same list since the Bayesian categories were - absorbed. Tracked as [open-issue 101](../issues/open.md). -- **F7 — Drop compute-and-ignore `result_kind` validation in - `_restore_persisted_fit_state`.** Replace with a validator helper or - move the warning into `fit_result.result_kind` setter. Tracked as - [open-issue 102](../issues/open.md). -- **F10 — Make `_sync_engine_from_minimizer_category` skip-keys - declarative.** This plan adds `proposal_moves` as a second - engine-level "ambient" key; introduce the `_engine_sync_skip_keys` - frozenset on `MinimizerCategoryBase` before adding the second member. - Tracked as [open-issue 103](../issues/open.md). - -When the matching open-issue is fully resolved, move it to -[`closed.md`](../issues/closed.md) and update -[`adrs/index.md`](../adrs/index.md) if relevant. - -## Concrete files likely to change - -Created: - -- `src/easydiffraction/analysis/categories/minimizer/emcee.py` (concrete - `EmceeMinimizer` class). -- `tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py`. -- `tests/integration/fitting/test_emcee.py` (cross-check vs DREAM on a - shared toy fit; assert posterior medians agree to within tolerance). -- `docs/docs/tutorials/ed-23.py` (emcee + resume tutorial). - -Modified: - -- `src/easydiffraction/analysis/minimizers/enums.py` (add - `MinimizerTypeEnum.EMCEE`). -- `src/easydiffraction/analysis/categories/minimizer/__init__.py` (add - the explicit `EmceeMinimizer` import to trigger registration). -- `src/easydiffraction/analysis/categories/minimizer/factory.py` (the - factory may need no change if registration uses `@Factory.register`). -- `src/easydiffraction/analysis/analysis.py` (`fit()` signature gains - `resume: bool = False, extra_steps: int | None = None`; route to the - live engine appropriately). -- `src/easydiffraction/io/results_sidecar.py` (read path: when - `/emcee_chain` is present, expose a small helper to construct an - `emcee.backends.HDFBackend(path, name='emcee_chain', read_only=...)`). -- `pyproject.toml` and `pixi.toml` (add `emcee>=3.1` dependency). - -## Implementation steps (Phase 1) - -- [ ] **P1.1 — Add emcee dependency.** Add `emcee>=3.1` to - `pyproject.toml` and `pixi.toml`. Run `pixi install` locally to - verify resolution. Commit: `Add emcee dependency` - -- [ ] **P1.2 — Register `MinimizerTypeEnum.EMCEE`.** Add the enum member - with value `'emcee'`. No other code wiring yet. Commit: - `Register emcee minimizer enum value` - -- [ ] **P1.3 — Add `EmceeMinimizer` concrete class.** Descriptor setup - follows the prerequisite plan's accepted helper pattern: - class-level defaults for emcee-specific values - (`sampling_steps=5000`, `population_size=32`, …, - `proposal_moves='stretch'`) and instance descriptors constructed - from the Bayesian minimizer helpers. Before wiring emcee, decide - whether DREAM's direct-engine `DreamPopulationInitializationEnum` - remains broader than the persisted `InitializationMethodEnum` - subset or is narrowed to match it. Implement `_native_kwargs()` - mapping to emcee's - `EnsembleSampler.run_mcmc(nsteps=..., progress=..., ...)`. Update - `src/easydiffraction/analysis/categories/minimizer/__init__.py` to - import `EmceeMinimizer` (registration trigger). Commit: - `Add EmceeMinimizer concrete class` - -- [ ] **P1.4 — Implement run + resume via HDFBackend.** In the live - solver layer (the new `Analysis._engine` path introduced in the - prerequisite plan), instantiate - `emcee.backends.HDFBackend(project.analysis_dir / 'results.h5', name='emcee_chain')`. - Lifecycle: - - **New fit** (`fit()` without `resume`): the prerequisite plan's - `Analysis.fit()` truncates `results.h5` _before_ the engine is asked - to sample (P1.10 in that plan). After truncation, the `HDFBackend` - is instantiated against the freshly recreated file and - `EnsembleSampler.run_mcmc(...)` is called. - - **Resume** (`fit(resume=True, extra_steps=N)`): - - require the active minimizer's `MinimizerTypeEnum` to support - resume (currently only `EMCEE`); - - require `results.h5` to exist and contain a `/emcee_chain` group - (raise `FileNotFoundError` / `ValueError` with a clear message - otherwise); - - reload the backend, validate `backend.shape` matches the current - parameter count (raise `ValueError` on mismatch with a clear - message and recommend starting a fresh fit); - - bypass the truncate-and-warn step; - - call - `run_mcmc(initial_state=None, nsteps=N, progress=True, skip_initial_state_check=True)` - to extend the chain. Translate the sampler's state to - `BayesianFitResults` exactly like DREAM, populating - `Parameter.posterior` via the existing helpers. Commit: - `Implement emcee run and resume via HDFBackend` - -- [ ] **P1.5 — Plug emcee outputs into existing posterior pipeline.** - Verify the existing sidecar writer for `/posterior`, - `/distribution_cache`, `/pair_cache`, `/predictive` correctly - picks up emcee results. Adjust only where emcee surfaces data - differently from DREAM (e.g. - `EnsembleSampler.get_chain(flat=False, discard=burn, thin=thin)` - vs the DREAM extraction helper). Cache derivations (KDE, pair - grids) must match the existing format. Commit: - `Route emcee posterior through sidecar pipeline` - -- [ ] **P1.6 — Add `ed-23.py` tutorial.** New notebook source at - `docs/docs/tutorials/ed-23.py`: demonstrate - `analysis.minimizer_type = 'emcee'`, a short run, save, resume - with `extra_steps=`, and a posterior plot. Run - `pixi run notebook-prepare` to generate the `.ipynb`. Commit: - `Add ed-23 emcee tutorial` - -- [ ] **P1.7 — Phase 1 review gate.** Stop and request user review - before Phase 2. - -## Verification (Phase 2) - -Same log-capture pattern as the prerequisite plan; commands repeated for -completeness. - -- [ ] **P2.1 — Add unit + integration tests.** - - `tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py`: - descriptor defaults, native-key mapping, swap behavior, resume - parameter-set-mismatch error path (no real sampler). - - `tests/integration/fitting/test_emcee.py`: end-to-end fit on a small - synthetic problem; resume; assert posterior medians agree with a - DREAM run within tolerance. - -- [ ] **P2.2 — Auto-fixes and static checks.** - - ``` - pixi run fix > /tmp/easydiffraction-fix.log 2>&1; \ - fix_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-fix.log; \ - exit $fix_exit_code - ``` - - ``` - pixi run check > /tmp/easydiffraction-check.log 2>&1; \ - check_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-check.log; \ - exit $check_exit_code - ``` - -- [ ] **P2.3 — Unit tests.** - - ``` - pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; \ - unit_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-unit-tests.log; \ - exit $unit_tests_exit_code - ``` - -- [ ] **P2.4 — Integration tests.** - - ``` - pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; \ - integration_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-integration-tests.log; \ - exit $integration_tests_exit_code - ``` - -- [ ] **P2.5 — Script tests.** - ``` - pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; \ - script_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-script-tests.log; \ - exit $script_tests_exit_code - ``` - -## Suggested Pull Request - -**Title:** Add emcee Bayesian sampler with resumable runs - -**Description (user-facing):** - -EasyDiffraction adds emcee — a widely-used affine-invariant MCMC sampler -— as a second Bayesian fitter. It is selected exactly like the existing -samplers: - -- `project.analysis.minimizer_type = 'emcee'` -- `project.analysis.minimizer.sampling_steps = 5000` -- `project.analysis.fit()` - -Long runs can be **resumed** without starting over: - -- `project.analysis.fit(resume=True, extra_steps=2000)` - -emcee's chain state lives inside the same `analysis/results.h5` file as -the other posterior data, so saving and reopening a project is a -single-file affair. Plots, parameter posteriors, and tables work the -same as for the existing DREAM sampler, so switching between samplers to -cross-check results is straightforward. - -A new tutorial (`ed-23`) walks through a short run, saving the project, -and resuming for additional steps. diff --git a/docs/dev/plans/minimizer-input-output-split.md b/docs/dev/plans/minimizer-input-output-split.md deleted file mode 100644 index 0d9361a8a..000000000 --- a/docs/dev/plans/minimizer-input-output-split.md +++ /dev/null @@ -1,674 +0,0 @@ -# Plan: Minimizer Input/Output Split - -> This plan follows -> [`.github/copilot-instructions.md`](../../../.github/copilot-instructions.md). -> No deliberate exceptions. - -## ADR - -Implements -[`docs/dev/adrs/suggestions/minimizer-input-output-split.md`](../adrs/suggestions/minimizer-input-output-split.md). -This plan promotes that ADR from Suggestion → Accepted during -implementation (step P1.16). - -Affected ADRs that this plan amends (per the ADR's §"ADRs amended"): - -- [`accepted/minimizer-category-consolidation.md`](../adrs/accepted/minimizer-category-consolidation.md) - — §1 becomes a partial rule; §"Alternatives Considered → D" records - the reversal. -- [`accepted/analysis-cif-fit-state.md`](../adrs/accepted/analysis-cif-fit-state.md) - — §"Minimizer fit projection" rewritten for the settings-only - `_minimizer.*` / outputs-on-`_fit_result.*` shape. -- [`accepted/runtime-fit-results.md`](../adrs/accepted/runtime-fit-results.md) - — closing paragraph references this ADR alongside the existing two. -- [`accepted/switchable-category-owned-selectors.md`](../adrs/accepted/switchable-category-owned-selectors.md) - — §1 gains the documented "fully-determined paired category" - exception. -- [`accepted/display-ux.md`](../adrs/accepted/display-ux.md) — - `project.display.fit.results()` prints a "Settings used" block. - -## Branch and PR - -- Branch: `minimizer-input-output-split` (continued from the branch the - ADR was drafted on). Do not push unless asked. -- Each step in §"Implementation steps (Phase 1)" must be staged with - explicit paths and committed locally **before** moving to the next - step. See `.github/copilot-instructions.md` → **Commits**. -- After P1.17, stop and wait for the user review gate before starting - Phase 2. - -## Decisions already made (from the ADR) - -1. `analysis.minimizer` holds **writable user settings only**. -2. `analysis.fit_result` becomes a class hierarchy paired with - `analysis.minimizer`. `FitResultBase` carries common fields; - `LeastSquaresFitResult` and `BayesianFitResult` add family-specific - ones. -3. `fit_result` is **not a user-facing switchable category**. No - `fit_result.type`, no `fit_result.show_supported()`. The owner's - `_swap_minimizer` hook installs both the minimizer and the paired - `fit_result` atomically. -4. Pairing rule is encoded on the minimizer base classes: - `LeastSquaresMinimizerBase._fit_result_class = LeastSquaresFitResult`, - `BayesianMinimizerBase._fit_result_class = BayesianFitResult`. -5. `objective_value` (raw χ²) and `reduced_chi_square` are distinct - fields, both kept on `LeastSquaresFitResult`. -6. `credible_interval_inner` / `credible_interval_outer` stay on the - output side (`BayesianFitResult`) at the fixed `0.68` / `0.95` - values. User-configurable levels deferred to a follow-on ADR. -7. The display extension lives under `project.display.fit.results()`; no - new `Analysis`-level display method is added. -8. Beta posture: hard cutover, no shims, no deprecation warnings. - Tutorials and saved fixtures regenerate. - -## Open questions - -- **Existing `analysis.fit_result.from_cif` parameter ordering.** After - this split, the CIF restore for `_fit_result.*` must run **after** - `_minimizer.*` is read so the paired class is known before the result - descriptors load. The current `_restore_*` order in - [`serialize.py`](../../../src/easydiffraction/io/cif/serialize.py) - reads `_minimizer.*` first via `_swap_minimizer`, then iterates the - rest. P1.6 must ensure `_fit_result` is included in the iteration only - after the swap has installed the paired class. Confirm during P1.6 - implementation. -- **Posterior-summary code path that currently writes - `_set_credible_interval_*` on `minimizer`.** After P1.11, the setters - move to `BayesianFitResult`. The `_store_posterior_fit_projection` - method in - [`analysis.py`](../../../src/easydiffraction/analysis/analysis.py) - must be updated to call - `self._fit_result._set_credible_interval_inner(...)` instead of - `self.minimizer._set_*`. Verify the order of operations against the - test in - [`test_results_sidecar.py`](../../../tests/unit/easydiffraction/io/test_results_sidecar.py). - -## Concrete files likely to change - -### Created - -- `src/easydiffraction/analysis/categories/fit_result/base.py` — rename - existing `FitResult` class to `FitResultBase` (or extract a base). - Common output descriptors live here. -- `src/easydiffraction/analysis/categories/fit_result/lsq.py` — - `LeastSquaresFitResult` with LSQ-specific output descriptors. -- `src/easydiffraction/analysis/categories/fit_result/bayesian.py` — - `BayesianFitResult` with Bayesian-specific output descriptors, - including `credible_interval_inner` / `credible_interval_outer`. -- _(`src/easydiffraction/analysis/categories/fit_result/factory.py` - already exists; this plan extends it rather than creating it. See P1.4 - — the factory becomes a registration helper for the two new family - classes; the authoritative swap mechanism is the `_fit_result_class` - attribute on the paired minimizer base, not a factory lookup. The - factory is still useful for introspection / testing.)_ -- `tests/unit/easydiffraction/analysis/categories/fit_result/test_base.py` -- `tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py` -- `tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py` -- `tests/unit/easydiffraction/analysis/categories/fit_result/test_factory.py` - -### Modified - -- `src/easydiffraction/analysis/categories/fit_result/__init__.py` — add - explicit imports for every new concrete class to trigger factory - registration. -- `src/easydiffraction/analysis/categories/fit_result/default.py` — - rewritten to import from the new family modules; the existing - `FitResult` is renamed to `FitResultBase` and absorbed. -- `src/easydiffraction/analysis/categories/minimizer/base.py` — add - `_fit_result_class: ClassVar[type]` declaration. -- `src/easydiffraction/analysis/categories/minimizer/lsq_base.py` — set - `_fit_result_class = LeastSquaresFitResult`; **remove** - `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, - `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, - `correlation_available`, `runtime_seconds`, `iterations_performed`, - `exit_reason` from descriptor declarations and from - `_result_descriptor_names`. `_setting_descriptor_names` stays - `('max_iterations',)`. -- `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py` — - set `_fit_result_class = BayesianFitResult`; remove `runtime_seconds`, - `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, - `credible_interval_outer`, `acceptance_rate_mean`, `gelman_rubin_max`, - `effective_sample_size_min`, `best_log_posterior` from descriptor - declarations and from `_result_descriptor_names`. - `_setting_descriptor_names` keeps the seven Bayesian inputs. -- `src/easydiffraction/analysis/analysis.py` — extend `_swap_minimizer` - to also instantiate the paired `fit_result` via the minimizer's - `_fit_result_class`; add `analysis.fit_result` property; route every - `self._minimizer._set_*` result-writer call in - `_store_least_squares_result_projection` / - `_store_posterior_fit_projection` / - `_restore_fit_results_from_projection` to `self._fit_result._set_*` - instead. -- `src/easydiffraction/io/cif/serialize.py` — emit/read `_fit_result.*` - from the paired class; remove the removed `_minimizer.*` output tags - from the serialise/deserialise paths. Update the legacy-tag rejection - message. -- `src/easydiffraction/project/display.py` — extend - `project.display.fit.results()` to print a "Settings used" block - populated from `analysis.minimizer.*` above the existing result - tables. -- All tutorials referencing `analysis.minimizer.` (e.g. - `runtime_seconds`, `gelman_rubin_max`, `objective_value`) → - `analysis.fit_result.`. List enumerated at P1.15 start - via `git grep`. -- Tests reading `analysis.minimizer.` → migrate to - `analysis.fit_result.`. P2.1 enumerates. - -### Deleted - -- None. The existing `fit_result/default.py` is rewritten in place; the - existing `FitResult` class becomes `FitResultBase`. - -## Implementation steps (Phase 1) - -Mark `[x]` as each step lands. - -- [x] **P1.1 — Rename `FitResult` to `FitResultBase`; add reset hooks; - update every import site.** In - `src/easydiffraction/analysis/categories/fit_result/default.py`, - rename the class. The factory `@register` decorator stays on the - renamed class so the default-tag lookup keeps working until P1.4 - extends the factory. - - Add two class-level hooks to `FitResultBase` matching the - `MinimizerCategoryBase` shape introduced by the consolidation - work - ([`minimizer/base.py:69-74`](../../../src/easydiffraction/analysis/categories/minimizer/base.py)): - - ```python - _result_descriptor_names: ClassVar[tuple[str, ...]] = ( - 'success', 'message', 'iterations', - 'fitting_time', 'reduced_chi_square', 'result_kind', - ) - - def _reset_result_descriptors(self) -> None: - """Reset fit-result descriptors to declared defaults.""" - for name in self._result_descriptor_names: - descriptor = getattr(self, name) - if isinstance(descriptor, GenericDescriptorBase): - descriptor.value = descriptor._value_spec.default_value() - ``` - - `LeastSquaresFitResult` (P1.2) and `BayesianFitResult` (P1.3) - then add their own field names to `_result_descriptor_names` - so the inherited helper resets every relevant descriptor. - - This must land in P1.1 because P1.6 retargets - `_clear_minimizer_result_projection` (renamed - `_clear_fit_result_projection`) to call - `self.fit_result._reset_result_descriptors()`, and that method - must exist on `FitResultBase` before the swap is wired. - - Update every package-level import that referenced the old - name. `git grep -nP '\bFitResult\b' src/ tests/` lists the - sites at plan time: - - - `src/easydiffraction/analysis/__init__.py` (line 14 today) - - `src/easydiffraction/analysis/categories/__init__.py` - (line 14 today) - - `src/easydiffraction/analysis/categories/fit_result/__init__.py` - - `src/easydiffraction/analysis/analysis.py` (import line 18; - type annotation on the `fit_result` property at line 432; - `self._fit_result = FitResult()` at line 483; same - construction at line 1208) - - All four `FitResult` import/annotation/construction sites in - `analysis.py` become `FitResultBase` after this step. The two - `self._fit_result = FitResult()` construction sites (init and - `_clear_persisted_fit_state`) become - `FitResultBase()` temporarily; P1.6 retargets them to the - paired class. - - Re-run `git grep -nP '\bFitResult\b' src/` at the end of this - step — every remaining hit must be the renamed class name or a - module path, not the old bare class. Tests are migrated by - P2.1. - - Commit: `Rename FitResult to FitResultBase, add reset hooks` - -- [x] **P1.2 — Add `LeastSquaresFitResult` class.** New file - `src/easydiffraction/analysis/categories/fit_result/lsq.py`. - `LeastSquaresFitResult(FitResultBase)` declares: `objective_name`, - `objective_value`, `n_data_points`, `n_parameters`, - `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, - `correlation_available`, `exit_reason`. **All defaults are `None` - with `allow_none=True`**, matching the consolidation cleanup that - previously moved LSQ outputs off `0` / `false` / `''` so a pre-fit - CIF emits `?` rather than a value that looks like a degenerate - result. This applies to numeric, integer-like, string, and bool - fields alike; the descriptor helpers in - `LeastSquaresMinimizerBase` - ([`lsq_base.py`](../../../src/easydiffraction/analysis/categories/minimizer/lsq_base.py)) - that currently produce these descriptors are the model — they can - be lifted into `LeastSquaresFitResult` verbatim before being - removed from `lsq_base.py` at P1.9. Declare - `_expected_descriptor_names`, `_result_descriptor_names` for - parity with the minimizer hierarchy. Tests deferred to Phase 2. - Commit: `Add LeastSquaresFitResult class` - -- [x] **P1.3 — Add `BayesianFitResult` class.** New file - `src/easydiffraction/analysis/categories/fit_result/bayesian.py`. - `BayesianFitResult(FitResultBase)` declares: - `point_estimate_name`, `sampler_completed`, - `credible_interval_inner` (default `0.68`), - `credible_interval_outer` (default `0.95`), - `acceptance_rate_mean`, `gelman_rubin_max`, - `effective_sample_size_min`, `best_log_posterior`. Declare - `_expected_descriptor_names`, `_result_descriptor_names`. Tests - deferred to Phase 2. Commit: `Add BayesianFitResult class` - -- [x] **P1.4 — Register fit-result classes with the existing - `FitResultFactory`.** The factory already exists at - [`src/easydiffraction/analysis/categories/fit_result/factory.py`](../../../src/easydiffraction/analysis/categories/fit_result/factory.py) - and currently registers only the default common class. Update it - to also register `LeastSquaresFitResult` and `BayesianFitResult` - with their family tags. Update - `src/easydiffraction/analysis/categories/fit_result/__init__.py` - to explicitly import every concrete class (so registration fires - on package import, per the repo's standard pattern). - - **Authoritative mechanism:** `Analysis._swap_minimizer` - constructs the paired fit-result via the minimizer's - `_fit_result_class` attribute (P1.5), not via a factory - lookup. The factory is kept as a registration helper for - introspection and testing; do not add a public selector surface - (`type`, `show_supported`) since `fit_result` is internally - paired, per ADR §1. Commit: - `Register fit-result family classes with factory` - -- [x] **P1.5 — Declare `_fit_result_class` on minimizer bases.** In - `src/easydiffraction/analysis/categories/minimizer/lsq_base.py`, - add `_fit_result_class: ClassVar[type] = LeastSquaresFitResult`. - In - `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py`, - add `_fit_result_class: ClassVar[type] = BayesianFitResult`. Add - the matching declaration to - `src/easydiffraction/analysis/categories/minimizer/base.py` with - `_fit_result_class: ClassVar[type] = FitResultBase` as a safety - fallback (no concrete minimizer instantiates the bare base, but - `_swap_minimizer` reads through this attribute). Commit: - `Declare paired _fit_result_class on minimizer bases` - -- [x] **P1.6 — Wire `Analysis._swap_minimizer` to install both - instances, and update every `_fit_result` reset path.** In - `src/easydiffraction/analysis/analysis.py`: - - `__init__` constructs the initial `_fit_result` from the default - minimizer's `_fit_result_class`: - `self._fit_result = self._minimizer._fit_result_class()`. The line - 483 `self._fit_result = FitResultBase()` (after P1.1) is replaced. - - `_replace_minimizer` constructs - `self._fit_result = new_minimizer._fit_result_class()` after the new - minimizer is created. The old `fit_result` is detached - (`_parent = None`) before being replaced. - - `_clear_persisted_fit_state` (line 1204 today) currently calls - `self._clear_minimizer_result_projection()` and then - `self._fit_result = FitResult()`. After P1.1 + the split, both lines - must change: - - `self._fit_result = self.minimizer._fit_result_class()` replaces - the bare `FitResultBase()` construction. This keeps the paired - class invariant whenever the persisted state is reset. - - `self._clear_minimizer_result_projection()` currently calls - `self.minimizer._reset_result_descriptors()`. After P1.9/P1.10 - remove the result descriptors from the minimizer, this method - becomes a no-op. **Retarget it to - `self.fit_result._reset_result_descriptors()`** and rename it to - `_clear_fit_result_projection`. Update the call sites (line 1204; - potentially others — `git grep` confirms). - - Add `analysis.fit_result` read-only property - (`return self._fit_result`). Type annotation: `FitResultBase` (the - family classes inherit from it). - - Wire `self._fit_result._parent = self` in - `_attach_category_parents`. Every `_fit_result` reassignment in the - methods above must also set `_parent` on the new instance. - - Verification at the end of this step: - - ``` - git grep -nE 'self\._fit_result\s*=' src/easydiffraction/analysis/analysis.py - ``` - - Every match must construct via `self.minimizer._fit_result_class()` - (or `new_minimizer._fit_result_class()` in `_replace_minimizer`), not - a bare class name. There must be no remaining - `self._fit_result = FitResultBase()` after this step. - - Commit: `Wire fit_result swap and reset paths to paired class` - -- [x] **P1.7 — Route LSQ result writers to `fit_result`.** In - `src/easydiffraction/analysis/analysis.py`, - `_store_least_squares_result_projection` currently writes to - `self.minimizer._set_objective_name(...)` etc. Reroute every such - call to `self.fit_result._set_*`. Same for - `_restore_fit_results_from_projection`'s LSQ branch (it reads - `self.minimizer.objective_name.value` etc. — change to - `self.fit_result..value`). Commit: - `Route LSQ result writers to fit_result` - -- [x] **P1.8 — Route Bayesian result writers to `fit_result`.** Same - treatment for `_store_posterior_fit_projection` and the Bayesian - branch of `_restore_fit_results_from_projection`. Includes the - `_set_credible_interval_*` calls — they now target - `self.fit_result._set_credible_interval_*`. Commit: - `Route Bayesian result writers to fit_result` - -- [x] **P1.9 — Remove output fields from LSQ minimizer base.** In - `src/easydiffraction/analysis/categories/minimizer/lsq_base.py`, - delete the descriptor declarations and properties for - `objective_name`, `objective_value`, `n_data_points`, - `n_parameters`, `n_free_parameters`, `degrees_of_freedom`, - `covariance_available`, `correlation_available`, - `runtime_seconds`, `iterations_performed`, `exit_reason`. Remove - these names from `_expected_descriptor_names` and - `_result_descriptor_names`. `_setting_descriptor_names` stays - `('max_iterations',)`. After this step, `_result_descriptor_names` - on `LeastSquaresMinimizerBase` is `()` and - `_reset_result_descriptors()` is a no-op on every LSQ minimizer — - confirming the P1.6 retarget of - `_clear_minimizer_result_projection` to operate on - `self.fit_result` is the correct call site. - - Note: `optimizer_name` and `method_name` were already removed - by the consolidation work (`_engine_metadata` dict replaces - them); this step is the bulk removal of the remaining LSQ - outputs. - - Commit: `Remove LSQ output descriptors from minimizer base` - -- [x] **P1.10 — Remove duplicate fields from Bayesian minimizer base.** - In - `src/easydiffraction/analysis/categories/minimizer/bayesian_base.py`, - delete the descriptor declarations and properties for - `runtime_seconds`, `point_estimate_name`, `sampler_completed`, - `credible_interval_inner`, `credible_interval_outer`, - `acceptance_rate_mean`, `gelman_rubin_max`, - `effective_sample_size_min`, `best_log_posterior`. Remove these - names from `_expected_descriptor_names` and - `_result_descriptor_names`. The `_setting_descriptor_names` tuple - keeps the seven Bayesian inputs. - - Commit: `Remove Bayesian output descriptors from minimizer base` - -- [x] **P1.11 — Update CIF emit/read for the split.** In - `src/easydiffraction/io/cif/serialize.py`: - - **No category-list reordering is performed in this step.** Neither - `Analysis._serializable_categories()` nor - `Analysis._fit_state_categories()` is restructured. `fit_result` stays - conditionally included by `_fit_state_categories()` only when - `self._has_persisted_fit_state()` is true — exactly as today. Pre-fit - projects continue to emit no `_fit_result.*` block. - - The only changes in this step are content updates inside the existing - emit/read flow: - - `_minimizer.*` emit/read continues to handle settings only (the - minimizer category's `from_cif` walks its remaining descriptors - after P1.9 / P1.10 removed the output descriptors). - - `_fit_result.*` emit/read picks up the new family-specific - descriptors automatically because P1.6 wires the paired class - (`LeastSquaresFitResult` or `BayesianFitResult`) onto - `self._fit_result`. The existing - `analysis.fit_result.from_cif(block)` call inside - `_restore_common_fit_state` - ([`serialize.py:590`](../../../src/easydiffraction/io/cif/serialize.py)) - reads `_fit_result.*` tags into the already-paired class — no - reordering, no new call. - - The read-side already restores `minimizer.type` first - ([`serialize.py:553-555`](../../../src/easydiffraction/io/cif/serialize.py)), - so the paired-class swap fires before `fit_result.from_cif` runs. No - code change is required here. - - Update the legacy-tag rejection message in - `_raise_for_legacy_analysis_tags` to include the now-removed - `_minimizer.` tags (e.g. `_minimizer.runtime_seconds`, - `_minimizer.gelman_rubin_max`) as legacy markers that should raise a - clear error rather than load silently. - - Commit: `Serialize fit outputs to _fit_result.* tags` - -- [x] **P1.12 — Confirm `_fit_state_categories` returns the paired - `fit_result`.** In `src/easydiffraction/analysis/analysis.py`, - `_fit_state_categories()` already returns - `[self.fit_parameters, self.fit_result, self.fit_parameter_correlations]` - when persisted fit state exists. After P1.6 wires the paired-class - construction, `self.fit_result` is automatically the paired - `LeastSquaresFitResult` / `BayesianFitResult` instance — no method - body change is needed. The dead branch in `_fit_state_categories` - (review-9 finding F4, open issue #101) can be cleaned up here - since this step is already reading the function. The plan does not - require the cleanup; if taken, mention "closes #101" in the commit - message. - - Commit: `Confirm fit_result paired instance flows through serializer` - -- [x] **P1.13 — Update `project.display.fit.results()` to add a - "Settings used" block.** In - `src/easydiffraction/project/display.py`, extend the existing - results-display method to print, above the current tables, a - one-section table titled "Settings used" populated from - `analysis.minimizer.*`. Use the same `render_table` machinery the - rest of the display facade uses. Commit: - `Add settings-used block to fit.results display` - -- [x] **P1.14 — Amend the five accepted ADRs listed in §"ADR".** For - each, apply the matching paragraph from the ADR's §"ADRs amended" - section: - - `minimizer-category-consolidation.md` — §1 partial-rule - qualification; §"Alternatives Considered → D" reversal record. - - `analysis-cif-fit-state.md` — §"Minimizer fit projection" rewrite. - - `runtime-fit-results.md` — closing-paragraph reference. - - `switchable-category-owned-selectors.md` — §1 paired-category - exception paragraph. - - `display-ux.md` — `project.display.fit.results()` settings-block - note. - - Update `docs/dev/adrs/index.md` to add the new ADR row under - "Accepted" (per P1.16 promotion). - - Commit: `Amend affected ADRs for minimizer input/output split` - -- [x] **P1.15 — Update tutorials.** `git grep` `docs/docs/tutorials/` - for `analysis.minimizer.` references and rewrite - each per the migration table below. The two **collapsed** rows - target existing common fields on `FitResultBase` (already written - by the existing common projection writer); they are not 1:1 - renames of the old setter/getter name. The other rows are - moved-but-keep-the-name relocations. - - | Old (removed at P1.9 / P1.10) | New | Notes | - | --- | --- | --- | - | `analysis.minimizer.runtime_seconds` | `analysis.fit_result.fitting_time` | Collapsed onto existing common field; setter remains `fit_result._set_fitting_time(...)` (already in `FitResultBase`). | - | `analysis.minimizer.iterations_performed` | `analysis.fit_result.iterations` | Collapsed onto existing common field; setter remains `fit_result._set_iterations(...)`. | - | `analysis.minimizer.objective_name` | `analysis.fit_result.objective_name` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.objective_value` | `analysis.fit_result.objective_value` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.n_data_points` | `analysis.fit_result.n_data_points` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.n_parameters` | `analysis.fit_result.n_parameters` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.n_free_parameters` | `analysis.fit_result.n_free_parameters` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.degrees_of_freedom` | `analysis.fit_result.degrees_of_freedom` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.covariance_available` | `analysis.fit_result.covariance_available` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.correlation_available` | `analysis.fit_result.correlation_available` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.exit_reason` | `analysis.fit_result.exit_reason` | Moved to `LeastSquaresFitResult`. | - | `analysis.minimizer.point_estimate_name` | `analysis.fit_result.point_estimate_name` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.sampler_completed` | `analysis.fit_result.sampler_completed` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.credible_interval_inner` | `analysis.fit_result.credible_interval_inner` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.credible_interval_outer` | `analysis.fit_result.credible_interval_outer` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.acceptance_rate_mean` | `analysis.fit_result.acceptance_rate_mean` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.gelman_rubin_max` | `analysis.fit_result.gelman_rubin_max` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.effective_sample_size_min` | `analysis.fit_result.effective_sample_size_min` | Moved to `BayesianFitResult`. | - | `analysis.minimizer.best_log_posterior` | `analysis.fit_result.best_log_posterior` | Moved to `BayesianFitResult`. | - - Run `pixi run notebook-prepare` to regenerate the `.ipynb` - files. - - Verification grep (must return empty against - `docs/docs/tutorials/`): - - ``` - git grep -nE 'analysis\.minimizer\.(runtime_seconds|iterations_performed|objective_value|objective_name|n_data_points|n_parameters|n_free_parameters|degrees_of_freedom|covariance_available|correlation_available|exit_reason|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' docs/docs/tutorials/ - ``` - - Commit: `Update tutorials to read outputs from fit_result` - -- [x] **P1.16 — Promote ADR + update index.** - - `git mv docs/dev/adrs/suggestions/minimizer-input-output-split.md docs/dev/adrs/accepted/minimizer-input-output-split.md`. - Flip the Status header to `Accepted`. - - Move the seven `_reply-N.md` and seven `_review-N.md` siblings: keep - them next to the ADR if the project convention preserves history - under `accepted/`; delete them if the convention is to drop the - deliberation artefacts on promotion (the - `switchable-category-owned-selectors` precedent deleted them). Per - the precedent, delete on promotion. - - Update `docs/dev/adrs/index.md` — move the row for this ADR from - Suggestion → Accepted. - - Commit: `Promote minimizer-input-output-split ADR` - -- [x] **P1.17 — Phase 1 review gate.** No code change. Re-run the P1.15 - tutorial grep against `src/`, `docs/docs/tutorials/`, and - `tests/`. The `src/` and `docs/docs/tutorials/` scopes must return - empty. The `tests/` sweep is deferred to P2.1, which migrates the - tests. Then stop and request user review. After approval, proceed - to Phase 2. - -## Verification (Phase 2) - -Each command captures its log with a zsh-safe exit-code variable as -required by `.github/copilot-instructions.md` → **Workflow**. - -- [x] **P2.1 — Migrate existing tests off the removed minimizer output - fields.** `git grep` `tests/` for the same patterns as P1.15. - Apply the same migration table from P1.15 — including the two - collapsed rows (`runtime_seconds` → `fitting_time`, - `iterations_performed` → `iterations`) where the setter name also - changes. Examples: - - - Reader rewrite: - `analysis.minimizer.gelman_rubin_max` → - `analysis.fit_result.gelman_rubin_max`. - - Reader rewrite with collapse: - `analysis.minimizer.runtime_seconds` → - `analysis.fit_result.fitting_time`. - - Setter rewrite (moved-but-kept name): - `analysis.minimizer._set_gelman_rubin_max(...)` → - `analysis.fit_result._set_gelman_rubin_max(...)`. - - Setter rewrite (collapsed name): - `analysis.minimizer._set_runtime_seconds(...)` → - `analysis.fit_result._set_fitting_time(...)`. - - Layout check: - - ``` - pixi run test-structure-check > /tmp/easydiffraction-test-structure-check.log 2>&1; \ - test_structure_check_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-test-structure-check.log; \ - exit $test_structure_check_exit_code - ``` - - Final stale-reference grep (all four must return empty): - - ``` - git grep -nE 'analysis\.minimizer\.(runtime_seconds|iterations_performed|objective_value|objective_name|n_data_points|n_parameters|n_free_parameters|degrees_of_freedom|covariance_available|correlation_available|exit_reason|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' src/ docs/docs/tutorials/ tests/ - git grep -nE '_minimizer\.(runtime_seconds|iterations_performed|objective_value|point_estimate_name|sampler_completed|credible_interval_inner|credible_interval_outer|acceptance_rate_mean|gelman_rubin_max|effective_sample_size_min|best_log_posterior)' src/ docs/docs/tutorials/ tests/ - ``` - -- [x] **P2.2 — Add unit tests for new modules.** New tests under - `tests/unit/easydiffraction/analysis/categories/fit_result/`: - - `test_base.py` — `FitResultBase` defaults, - `_reset_result_descriptors`. - - `test_lsq.py` — `LeastSquaresFitResult` defaults; CIF round-trip of - LSQ outputs. - - `test_bayesian.py` — `BayesianFitResult` defaults including the - fixed credible interval levels; CIF round-trip. - - `test_factory.py` — pairing rule via - `LeastSquaresMinimizerBase._fit_result_class` and - `BayesianMinimizerBase._fit_result_class`. - - Layout check: - - ``` - pixi run test-structure-check > /tmp/easydiffraction-test-structure-check.log 2>&1; \ - test_structure_check_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-test-structure-check.log; \ - exit $test_structure_check_exit_code - ``` - -- [x] **P2.3 — Auto-fixes and static checks.** - - ``` - pixi run fix > /tmp/easydiffraction-fix.log 2>&1; \ - fix_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-fix.log; \ - exit $fix_exit_code - ``` - - Then: - - ``` - pixi run check > /tmp/easydiffraction-check.log 2>&1; \ - check_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-check.log; \ - exit $check_exit_code - ``` - - Iterate `pixi run check` until clean. Do not raise lint - thresholds — refactor instead. - -- [x] **P2.4 — Unit tests.** - - ``` - pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; \ - unit_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-unit-tests.log; \ - exit $unit_tests_exit_code - ``` - -- [x] **P2.5 — Integration tests.** - - ``` - pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; \ - integration_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-integration-tests.log; \ - exit $integration_tests_exit_code - ``` - -- [x] **P2.6 — Script tests.** - - ``` - pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; \ - script_tests_exit_code=$?; \ - tail -n 200 /tmp/easydiffraction-script-tests.log; \ - exit $script_tests_exit_code - ``` - - This regenerates `tmp/tutorials/projects/*` fixtures with the - new CIF layout (`_minimizer.*` settings-only, - `_fit_result.*` outputs). - -## Suggested Pull Request - -**Title:** Split minimizer settings from fit-result outputs - -**Description (user-facing):** - -`analysis.minimizer` now holds only the settings you can change — -`sampling_steps`, `max_iterations`, and the other input knobs. Once a -fit completes, every output the project records (wall time, χ², the -Bayesian diagnostics, the LSQ counters) lives on a paired -`analysis.fit_result`. The pairing happens automatically when you change -minimizer type, so users never see a separate result selector. - -`project.display.fit.results()` now prints a "Settings used" block above -the existing result tables, so the settings that produced the fit and -the outputs the fit produced are visible side-by-side without having to -open two namespaces. - -The CIF layout follows the same split: `_minimizer.*` holds settings -only, `_fit_result.*` holds outputs. Saved projects from the previous -layout do not load unchanged (the project is in beta; no legacy shims). -Tutorials and saved-fixture regeneration land in this PR. - -Incidental cleanup also bundled in this branch: the unused -`essdiffraction` development dependency is removed, and a handful of -unrelated functions are split into helpers to satisfy the project's -complexity thresholds during Phase 2 verification: -`singleton.ConstraintsHandler.apply_constraints`, -`analysis.sequential._fit_worker`, -`display.plotting._posterior_predictive_*`, and -`calculators.{crysfml,pdffit}._calculate_pattern`. diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb index 89f0488ca..8d7d9d2be 100644 --- a/docs/docs/tutorials/ed-17.ipynb +++ b/docs/docs/tutorials/ed-17.ipynb @@ -90,7 +90,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as(dir_path='projects/cosio_d20')" + "project.save_as(dir_path='projects/cosio_d20_scan')" ] }, { diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py index 3f8f9b1ed..0622b2310 100644 --- a/docs/docs/tutorials/ed-17.py +++ b/docs/docs/tutorials/ed-17.py @@ -29,7 +29,7 @@ # results can be written to `analysis/results.csv`. # %% -project.save_as(dir_path='projects/cosio_d20') +project.save_as(dir_path='projects/cosio_d20_scan') # %% [markdown] # ## Step 2: Define Crystal Structure diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index 2b02a7c63..285084dc5 100644 --- a/docs/docs/tutorials/ed-21.ipynb +++ b/docs/docs/tutorials/ed-21.ipynb @@ -24,7 +24,7 @@ "id": "1", "metadata": {}, "source": [ - "# Bayesian Analysis: LBCO, HRPT\n", + "# Bayesian Analysis (`bumps-dream`): LBCO, HRPT\n", "\n", "This tutorial demonstrates a practical two-stage workflow for powder\n", "diffraction analysis with EasyDiffraction.\n", @@ -97,7 +97,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/lbco_hrpt_bayesian')" + "project.save_as('projects/lbco_hrpt_bumps-dream')" ] }, { diff --git a/docs/docs/tutorials/ed-21.py b/docs/docs/tutorials/ed-21.py index 563992ee1..13838dbb2 100644 --- a/docs/docs/tutorials/ed-21.py +++ b/docs/docs/tutorials/ed-21.py @@ -1,5 +1,5 @@ # %% [markdown] -# # Bayesian Analysis: LBCO, HRPT +# # Bayesian Analysis (`bumps-dream`): LBCO, HRPT # # This tutorial demonstrates a practical two-stage workflow for powder # diffraction analysis with EasyDiffraction. @@ -41,7 +41,7 @@ project = ed.Project() # %% -project.save_as('projects/lbco_hrpt_bayesian') +project.save_as('projects/lbco_hrpt_bumps-dream') # %% [markdown] # ## Step 2: Build the Structural Model diff --git a/docs/docs/tutorials/ed-22.ipynb b/docs/docs/tutorials/ed-22.ipynb index 8825c90dc..fa0c91566 100644 --- a/docs/docs/tutorials/ed-22.ipynb +++ b/docs/docs/tutorials/ed-22.ipynb @@ -24,7 +24,7 @@ "id": "1", "metadata": {}, "source": [ - "# Bayesian Analysis: Tb2TiO7, HEiDi\n", + "# Bayesian Analysis: Tb2TiO7 (`emcee`), HEiDi\n", "\n", "This tutorial demonstrates a practical two-stage workflow for single-crystal\n", "diffraction analysis with EasyDiffraction.\n", @@ -32,7 +32,7 @@ "In the first stage, we run a fast local refinement to obtain a sensible\n", "point estimate and parameter uncertainties. In the second stage, we use\n", "these refined values to define fit bounds and then sample the posterior\n", - "distribution with BUMPS-DREAM.\n", + "distribution with emcee.\n", "\n", "The example uses constant-wavelength neutron single-crystal diffraction data\n", "for Tb2TiO7 measured on HEiDi at FRM II.\n", @@ -88,9 +88,19 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "6", "metadata": {}, + "outputs": [], + "source": [ + "project.save_as('projects/tbti_heidi_emcee')" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, "source": [ "## Step 2: Build the Structural Model\n", "\n", @@ -103,7 +113,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -113,7 +123,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -123,7 +133,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -132,7 +142,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "## Step 3: Define the Diffraction Experiment\n", @@ -145,7 +155,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -155,7 +165,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -171,7 +181,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -180,7 +190,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "15", "metadata": {}, "source": [ "Link the crystal structure to the experiment and set its scale factor." @@ -189,7 +199,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -199,7 +209,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "17", "metadata": {}, "source": [ "Set the instrument wavelength and starting extinction parameters.\n", @@ -210,7 +220,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -220,7 +230,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -230,7 +240,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "## Step 4: Run an Initial Local Refinement\n", @@ -250,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -269,7 +279,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -279,7 +289,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "23", "metadata": {}, "source": [ "We keep using the default LMFIT Levenberg-Marquardt minimizer as a fast local\n", @@ -290,7 +300,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -300,7 +310,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -309,7 +319,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "The fit-results display summarizes the locally refined values and their\n", @@ -319,7 +329,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -328,7 +338,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "28", "metadata": {}, "source": [ "The correlation plot shows how strongly the refined parameters move\n", @@ -340,7 +350,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -350,7 +360,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -359,12 +369,12 @@ }, { "cell_type": "markdown", - "id": "30", + "id": "31", "metadata": {}, "source": [ "## Step 5: Prepare for Bayesian Sampling\n", "\n", - "DREAM requires finite bounds for the free parameters. Instead of\n", + "Bayesian samplers require finite bounds for the free parameters. Instead of\n", "setting them manually, we derive them from the uncertainties estimated\n", "in the local refinement.\n", "\n", @@ -383,7 +393,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -392,7 +402,7 @@ }, { "cell_type": "markdown", - "id": "32", + "id": "33", "metadata": {}, "source": [ "Set fit bounds for all free parameters using `multiplier=1.5`. In this\n", @@ -404,7 +414,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -414,7 +424,7 @@ }, { "cell_type": "markdown", - "id": "34", + "id": "35", "metadata": {}, "source": [ "Displaying the free parameters again is a convenient way to confirm\n", @@ -425,7 +435,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -434,21 +444,18 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "37", "metadata": {}, "source": [ - "## Step 6: Configure and Run DREAM\n", + "## Step 6: Configure and Run emcee\n", "\n", - "We now switch from the local minimizer to the Bayesian DREAM sampler.\n", + "We now switch from the local minimizer to the Bayesian emcee sampler.\n", "\n", "The settings below are intentionally small so the tutorial runs\n", "quickly. For production analysis you would usually increase the number\n", - "of steps (`steps`) and often the burn-in (`burn`) as well. When\n", - "needed, the DREAM API also lets you tune how chains are initialized\n", - "through the `init` setting. Other sampler settings such as `thin` and\n", - "`pop` can be adjusted as well. The current EasyDiffraction defaults\n", - "use `steps=3000`, `init='lhs'`, and `parallel=0`, which tells\n", - "BUMPS-DREAM to use all available CPUs for population evaluations.\n", + "of steps and often the burn-in as well. emcee also lets you tune how\n", + "walkers are initialized, how many walkers are used, and which proposal\n", + "move drives the ensemble.\n", "\n", "The `burn` setting is auto-resolved when left unset. Here we override\n", "`steps` with a smaller value to keep the tutorial fast, and the\n", @@ -458,7 +465,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -468,28 +475,29 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ - "project.analysis.minimizer.type = 'bumps (dream)'" + "project.analysis.minimizer.type = 'emcee'" ] }, { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "40", "metadata": {}, "outputs": [], "source": [ - "project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000\n", - "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600" + "project.analysis.minimizer.sampling_steps = 500 # lower than the default 3000\n", + "project.analysis.minimizer.burn_in_steps = 100 # lower than the default 600\n", + "project.analysis.minimizer.population_size = 16 # lower than the default 32" ] }, { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -498,7 +506,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "42", "metadata": {}, "source": [ "## Step 7: Inspect Bayesian Results\n", @@ -511,7 +519,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "43", "metadata": {}, "outputs": [], "source": [ @@ -520,7 +528,7 @@ }, { "cell_type": "markdown", - "id": "43", + "id": "44", "metadata": {}, "source": [ "The correlation and posterior-pair plots are complementary:\n", @@ -537,7 +545,7 @@ { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "45", "metadata": {}, "outputs": [], "source": [ @@ -547,7 +555,7 @@ { "cell_type": "code", "execution_count": null, - "id": "45", + "id": "46", "metadata": {}, "outputs": [], "source": [ @@ -556,7 +564,7 @@ }, { "cell_type": "markdown", - "id": "46", + "id": "47", "metadata": {}, "source": [ "The one-dimensional posterior distributions below make it easier to\n", @@ -567,7 +575,7 @@ { "cell_type": "code", "execution_count": null, - "id": "47", + "id": "48", "metadata": {}, "outputs": [], "source": [ @@ -576,7 +584,7 @@ }, { "cell_type": "markdown", - "id": "48", + "id": "49", "metadata": {}, "source": [ "Finally, the posterior predictive plot propagates the sampled\n", @@ -587,7 +595,7 @@ { "cell_type": "code", "execution_count": null, - "id": "49", + "id": "50", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/docs/tutorials/ed-22.py b/docs/docs/tutorials/ed-22.py index 4b6c99eab..52fd31662 100644 --- a/docs/docs/tutorials/ed-22.py +++ b/docs/docs/tutorials/ed-22.py @@ -1,5 +1,5 @@ # %% [markdown] -# # Bayesian Analysis: Tb2TiO7, HEiDi +# # Bayesian Analysis: Tb2TiO7 (`emcee`), HEiDi # # This tutorial demonstrates a practical two-stage workflow for single-crystal # diffraction analysis with EasyDiffraction. @@ -7,7 +7,7 @@ # In the first stage, we run a fast local refinement to obtain a sensible # point estimate and parameter uncertainties. In the second stage, we use # these refined values to define fit bounds and then sample the posterior -# distribution with BUMPS-DREAM. +# distribution with emcee. # # The example uses constant-wavelength neutron single-crystal diffraction data # for Tb2TiO7 measured on HEiDi at FRM II. @@ -37,6 +37,9 @@ # %% project = ed.Project() +# %% +project.save_as('projects/tbti_heidi_emcee') + # %% [markdown] # ## Step 2: Build the Structural Model # @@ -158,7 +161,7 @@ # %% [markdown] # ## Step 5: Prepare for Bayesian Sampling # -# DREAM requires finite bounds for the free parameters. Instead of +# Bayesian samplers require finite bounds for the free parameters. Instead of # setting them manually, we derive them from the uncertainties estimated # in the local refinement. # @@ -195,18 +198,15 @@ project.display.parameters.free() # %% [markdown] -# ## Step 6: Configure and Run DREAM +# ## Step 6: Configure and Run emcee # -# We now switch from the local minimizer to the Bayesian DREAM sampler. +# We now switch from the local minimizer to the Bayesian emcee sampler. # # The settings below are intentionally small so the tutorial runs # quickly. For production analysis you would usually increase the number -# of steps (`steps`) and often the burn-in (`burn`) as well. When -# needed, the DREAM API also lets you tune how chains are initialized -# through the `init` setting. Other sampler settings such as `thin` and -# `pop` can be adjusted as well. The current EasyDiffraction defaults -# use `steps=3000`, `init='lhs'`, and `parallel=0`, which tells -# BUMPS-DREAM to use all available CPUs for population evaluations. +# of steps and often the burn-in as well. emcee also lets you tune how +# walkers are initialized, how many walkers are used, and which proposal +# move drives the ensemble. # # The `burn` setting is auto-resolved when left unset. Here we override # `steps` with a smaller value to keep the tutorial fast, and the @@ -216,11 +216,12 @@ project.analysis.minimizer.show_supported() # %% -project.analysis.minimizer.type = 'bumps (dream)' +project.analysis.minimizer.type = 'emcee' # %% -project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000 -project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600 +project.analysis.minimizer.sampling_steps = 500 # lower than the default 3000 +project.analysis.minimizer.burn_in_steps = 100 # lower than the default 600 +project.analysis.minimizer.population_size = 16 # lower than the default 32 # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-24.ipynb b/docs/docs/tutorials/ed-24.ipynb index 4e77ed733..904635418 100644 --- a/docs/docs/tutorials/ed-24.ipynb +++ b/docs/docs/tutorials/ed-24.ipynb @@ -24,7 +24,7 @@ "id": "1", "metadata": {}, "source": [ - "# Load Saved Bayesian Project: LBCO, HRPT\n", + "# Bayesian Analysis Display (`bumps-dream`): LBCO, HRPT\n", "\n", "This tutorial shows how to reopen the Bayesian project created in\n", "`ed-21.py` and inspect the saved fit results without rerunning DREAM.\n", @@ -49,49 +49,7 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path\n", - "\n", - "import easydiffraction as ed\n", - "\n", - "\n", - "# The ID 35 archive used below was saved before the\n", - "# switchable-category-owned-selectors refactor renamed several CIF\n", - "# tags. The helper below rewrites the archive in place so the tutorial\n", - "# can load it; it is intentionally narrow (ID 35 only, hrpt only,\n", - "# line-segment background only) and not a general legacy migration\n", - "# path. EasyDiffraction is in beta and does not ship legacy CIF\n", - "# shims, so saved projects in the old layout must be regenerated. The\n", - "# helper will be deleted once the upstream archive is republished\n", - "# under the current tag names.\n", - "def _normalize_id35_archive_for_tutorial(project_dir):\n", - " \"\"\"Rewrite the ID 35 archive's CIF tags for the current API.\"\"\"\n", - " project_path = Path(project_dir)\n", - "\n", - " replacements_by_file = {\n", - " project_path / 'project.cif': {\n", - " '_rendering.chart_engine': '_chart.type',\n", - " '_rendering.table_engine': '_table.type',\n", - " },\n", - " project_path / 'analysis' / 'analysis.cif': {\n", - " '_fitting.mode_type': '_fitting_mode.type',\n", - " '_fitting.minimizer_type': '_minimizer.type',\n", - " },\n", - " project_path / 'experiments' / 'hrpt.cif': {\n", - " '_calculation.calculator_type': '_calculator.type',\n", - " '_peak.profile_type': '_peak.type',\n", - " },\n", - " }\n", - "\n", - " for file_path, replacements in replacements_by_file.items():\n", - " text = file_path.read_text(encoding='utf-8')\n", - " for old, new in replacements.items():\n", - " text = text.replace(old, new)\n", - " if file_path.name == 'hrpt.cif' and '_background.type' not in text:\n", - " text = text.replace(\n", - " '\\nloop_\\n_pd_background.id\\n',\n", - " '\\n_background.type line-segment\\nloop_\\n_pd_background.id\\n',\n", - " )\n", - " file_path.write_text(text, encoding='utf-8')" + "import easydiffraction as ed" ] }, { @@ -113,8 +71,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_dir = ed.download_data(id=35, destination='projects')\n", - "_normalize_id35_archive_for_tutorial(project_dir)" + "project_dir = ed.download_data(id=39, destination='projects')" ] }, { diff --git a/docs/docs/tutorials/ed-24.py b/docs/docs/tutorials/ed-24.py index 01f3daff9..f0330bc95 100644 --- a/docs/docs/tutorials/ed-24.py +++ b/docs/docs/tutorials/ed-24.py @@ -1,5 +1,5 @@ # %% [markdown] -# # Load Saved Bayesian Project: LBCO, HRPT +# # Bayesian Analysis Display (`bumps-dream`): LBCO, HRPT # # This tutorial shows how to reopen the Bayesian project created in # `ed-21.py` and inspect the saved fit results without rerunning DREAM. @@ -12,51 +12,8 @@ # ## Import Library # %% -from pathlib import Path - import easydiffraction as ed - -# The ID 35 archive used below was saved before the -# switchable-category-owned-selectors refactor renamed several CIF -# tags. The helper below rewrites the archive in place so the tutorial -# can load it; it is intentionally narrow (ID 35 only, hrpt only, -# line-segment background only) and not a general legacy migration -# path. EasyDiffraction is in beta and does not ship legacy CIF -# shims, so saved projects in the old layout must be regenerated. The -# helper will be deleted once the upstream archive is republished -# under the current tag names. -def _normalize_id35_archive_for_tutorial(project_dir): - """Rewrite the ID 35 archive's CIF tags for the current API.""" - project_path = Path(project_dir) - - replacements_by_file = { - project_path / 'project.cif': { - '_rendering.chart_engine': '_chart.type', - '_rendering.table_engine': '_table.type', - }, - project_path / 'analysis' / 'analysis.cif': { - '_fitting.mode_type': '_fitting_mode.type', - '_fitting.minimizer_type': '_minimizer.type', - }, - project_path / 'experiments' / 'hrpt.cif': { - '_calculation.calculator_type': '_calculator.type', - '_peak.profile_type': '_peak.type', - }, - } - - for file_path, replacements in replacements_by_file.items(): - text = file_path.read_text(encoding='utf-8') - for old, new in replacements.items(): - text = text.replace(old, new) - if file_path.name == 'hrpt.cif' and '_background.type' not in text: - text = text.replace( - '\nloop_\n_pd_background.id\n', - '\n_background.type line-segment\nloop_\n_pd_background.id\n', - ) - file_path.write_text(text, encoding='utf-8') - - # %% [markdown] # ## Download Saved Project # @@ -65,8 +22,7 @@ def _normalize_id35_archive_for_tutorial(project_dir): # caches. # %% -project_dir = ed.download_data(id=35, destination='projects') -_normalize_id35_archive_for_tutorial(project_dir) +project_dir = ed.download_data(id=39, destination='projects') # %% [markdown] # ## Load the Saved Bayesian Project diff --git a/docs/docs/tutorials/ed-25.ipynb b/docs/docs/tutorials/ed-25.ipynb new file mode 100644 index 000000000..d24eecfd3 --- /dev/null +++ b/docs/docs/tutorials/ed-25.ipynb @@ -0,0 +1,749 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "# Check whether easydiffraction is installed; install it if needed.\n", + "# Required for remote environments such as Google Colab.\n", + "import importlib.util\n", + "\n", + "if importlib.util.find_spec('easydiffraction') is None:\n", + " %pip install easydiffraction" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Bayesian Analysis (`emcee`): LBCO, HRPT\n", + "\n", + "This tutorial demonstrates a practical two-stage workflow for powder\n", + "diffraction analysis with EasyDiffraction.\n", + "\n", + "In the first stage, we run a fast local refinement to obtain a sensible\n", + "point estimate and parameter uncertainties. In the second stage, we use\n", + "these refined values to define fit bounds and then sample the posterior\n", + "distribution with emcee.\n", + "\n", + "The example uses constant-wavelength neutron powder diffraction data\n", + "for La0.5Ba0.5CoO3 measured on HRPT at PSI.\n", + "\n", + "The goal is not only to obtain a good fit, but also to answer Bayesian\n", + "questions such as:\n", + "\n", + "- Which parameter values are most probable?\n", + "- How broad are the credible intervals?\n", + "- Which parameters are strongly correlated?\n", + "- How much uncertainty propagates into the calculated diffraction\n", + " pattern?" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Import Library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import easydiffraction as ed" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Step 1: Create a Project Container\n", + "\n", + "The project object keeps structures, experiments, fit settings, and\n", + "plotting utilities together in a single place. We will build the full\n", + "workflow inside this object.\n", + "\n", + "Save the project to a directory early on so that you can easily reload\n", + "it later if needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "project = ed.Project()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as('projects/lbco_hrpt_emcee')" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Step 2: Build the Structural Model\n", + "\n", + "We define a simple cubic perovskite model for LBCO. La and Ba share the\n", + "same crystallographic site with equal occupancy, while Co and O occupy\n", + "the remaining ideal perovskite positions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "project.structures.create(name='lbco')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "structure = project.structures['lbco']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "structure.space_group.name_h_m = 'P m -3 m'\n", + "structure.space_group.it_coordinate_system_code = '1'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "structure.cell.length_a = 3.88" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "The atom-site definitions below form the starting structural model. The\n", + "parameters are intentionally reasonable rather than fully optimized,\n", + "because the refinement step will improve them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "structure.atom_sites.create(\n", + " label='La',\n", + " type_symbol='La',\n", + " fract_x=0,\n", + " fract_y=0,\n", + " fract_z=0,\n", + " wyckoff_letter='a',\n", + " adp_type='Biso',\n", + " adp_iso=0.5151,\n", + " occupancy=0.5,\n", + ")\n", + "structure.atom_sites.create(\n", + " label='Ba',\n", + " type_symbol='Ba',\n", + " fract_x=0,\n", + " fract_y=0,\n", + " fract_z=0,\n", + " wyckoff_letter='a',\n", + " adp_type='Biso',\n", + " adp_iso=0.5151,\n", + " occupancy=0.5,\n", + ")\n", + "structure.atom_sites.create(\n", + " label='Co',\n", + " type_symbol='Co',\n", + " fract_x=0.5,\n", + " fract_y=0.5,\n", + " fract_z=0.5,\n", + " wyckoff_letter='b',\n", + " adp_type='Biso',\n", + " adp_iso=0.2190,\n", + ")\n", + "structure.atom_sites.create(\n", + " label='O',\n", + " type_symbol='O',\n", + " fract_x=0,\n", + " fract_y=0.5,\n", + " fract_z=0.5,\n", + " wyckoff_letter='c',\n", + " adp_type='Biso',\n", + " adp_iso=1.3916,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Step 3: Define the Diffraction Experiment\n", + "\n", + "Next we download the measured powder pattern, create a neutron powder\n", + "experiment, and configure the instrument, profile, background, and\n", + "excluded regions." + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "Download the measured data from the repository. Alternatively, you\n", + "could use your own data file by providing the path to it instead of\n", + "downloading from the repository." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "data_path = ed.download_data(id=3, destination='data')" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "Create the experiment object and specify the sample form, beam mode,\n", + "and radiation probe." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "project.experiments.add_from_data_path(\n", + " name='hrpt',\n", + " data_path=data_path,\n", + " sample_form='powder',\n", + " beam_mode='constant wavelength',\n", + " radiation_probe='neutron',\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "experiment = project.experiments['hrpt']" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Link the structural phase to the experiment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.linked_phases.create(id='lbco', scale=9.1351)" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "Set instrument and peak profile parameters.\n", + "\n", + "These values provide the initial instrument description for the local\n", + "refinement. Later, a subset of them will be refined." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.instrument.setup_wavelength = 1.494\n", + "experiment.instrument.calib_twotheta_offset = 0.0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.peak.broad_gauss_u = 0.1\n", + "experiment.peak.broad_gauss_v = -0.1\n", + "experiment.peak.broad_gauss_w = 0.1204\n", + "experiment.peak.broad_lorentz_y = 0.0844" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "Add background points and excluded regions.\n", + "\n", + "The line-segment background is defined by a few anchor points. We also\n", + "exclude regions that are not intended to contribute to the fit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.background.create(id='1', x=10, y=168.5585)\n", + "experiment.background.create(id='2', x=30, y=164.3357)\n", + "experiment.background.create(id='3', x=50, y=166.8881)\n", + "experiment.background.create(id='4', x=110, y=175.4006)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.excluded_regions.create(id='1', start=0, end=10)\n", + "experiment.excluded_regions.create(id='2', start=100, end=180)" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "## Step 4: Run an Initial Local Refinement\n", + "\n", + "Before Bayesian sampling, it is useful to run a deterministic fit. This\n", + "gives us:\n", + "\n", + "- a good point estimate near the best-fit region,\n", + "- uncertainties from the local optimizer,\n", + "- a quick check that the model and experiment are configured\n", + " sensibly.\n", + "\n", + "In this tutorial we refine only a small set of parameters that are easy\n", + "to interpret in the later Bayesian stage." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "structure.cell.length_a.free = True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "experiment.linked_phases['lbco'].scale.free = True\n", + "experiment.peak.broad_gauss_u.free = True\n", + "experiment.peak.broad_gauss_v.free = True\n", + "experiment.instrument.calib_twotheta_offset.free = True" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "We keep LMFIT Levenberg-Marquardt minimizer as a fast local optimizer.\n", + "Its main purpose here is to provide a stable starting point and\n", + "uncertainty estimates for the Bayesian run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.minimizer.show_supported()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.fit()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.results()" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "The correlation plot shows how strongly the fitted parameters move\n", + "together in the local refinement. The measured-vs-calculated plots show\n", + "how well the refined model reproduces the data globally and in a zoomed\n", + "region." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.correlations()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.pattern(expt_name='hrpt')" + ] + }, + { + "cell_type": "markdown", + "id": "38", + "metadata": {}, + "source": [ + "## Step 5: Prepare for Bayesian Sampling\n", + "\n", + "Bayesian samplers require finite bounds for the free parameters. Instead of\n", + "setting them manually, we derive them from the uncertainties estimated\n", + "in the local refinement.\n", + "\n", + "The helper method `set_fit_bounds_from_uncertainty` centers the bounds\n", + "on the current parameter value and expands them by a chosen multiple of\n", + "the reported uncertainty.\n", + "\n", + "The default `multiplier` is 4. If the local refinement is very tight,\n", + "or if you expect a broader posterior, increase it explicitly.\n", + "\n", + "Show unset fit bounds before setting them from the local refinement uncertainties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.parameters.free()" + ] + }, + { + "cell_type": "markdown", + "id": "40", + "metadata": {}, + "source": [ + "Set fit bounds for all free parameters using the default multiplier of\n", + "4. In this tutorial that means the posterior pair plot will later\n", + "refer to a `±4 × uncertainty` region in its title. To use a different\n", + "region, pass another value, for example `multiplier=6`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [ + "for param in project.free_parameters:\n", + " param.set_fit_bounds_from_uncertainty()" + ] + }, + { + "cell_type": "markdown", + "id": "42", + "metadata": {}, + "source": [ + "Displaying the free parameters again is a convenient way to confirm\n", + "that the fit bounds have been assigned as expected before launching the\n", + "sampler." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.parameters.free()" + ] + }, + { + "cell_type": "markdown", + "id": "44", + "metadata": {}, + "source": [ + "## Step 6: Configure and Run emcee\n", + "\n", + "We now switch from the local minimizer to the Bayesian emcee sampler.\n", + "\n", + "The settings below are intentionally small so the tutorial runs\n", + "quickly. For production analysis you would usually increase the number\n", + "of steps and often the burn-in as well. emcee also lets you tune how\n", + "walkers are initialized, how many walkers are used, and which proposal\n", + "move drives the ensemble.\n", + "\n", + "The `burn` setting is auto-resolved when left unset. Here we override\n", + "`steps` with a smaller value to keep the tutorial fast, and the\n", + "effective burn-in is recomputed automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.minimizer.show_supported()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.minimizer.type = 'emcee'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.minimizer.sampling_steps = 100 # lower than the default 5000\n", + "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 1000\n", + "project.analysis.minimizer.population_size = 16 # lower than the default 32" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.fit()" + ] + }, + { + "cell_type": "markdown", + "id": "49", + "metadata": {}, + "source": [ + "## Step 7: Inspect Bayesian Results\n", + "\n", + "The fit-results display now includes sampler settings, convergence\n", + "diagnostics, committed parameter values, and posterior summary\n", + "statistics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.results()" + ] + }, + { + "cell_type": "markdown", + "id": "51", + "metadata": {}, + "source": [ + "The correlation and posterior-pair plots are complementary:\n", + "\n", + "- `plot_param_correlations` summarizes pairwise structure in a compact\n", + " matrix.\n", + "- `plot_posterior_pairs` shows marginal densities on the diagonal and\n", + " posterior contours off-diagonal. In this tutorial its title also\n", + " reminds you that the display region follows the `±4 × uncertainty`\n", + " bounds defined above, while numeric subplot ranges are omitted to\n", + " keep the grid readable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.correlations()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.pairs()" + ] + }, + { + "cell_type": "markdown", + "id": "54", + "metadata": {}, + "source": [ + "The one-dimensional posterior distributions below make it easier to\n", + "inspect individual parameters in isolation, including asymmetry or\n", + "multimodality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.distribution()" + ] + }, + { + "cell_type": "markdown", + "id": "56", + "metadata": {}, + "source": [ + "Finally, the posterior predictive plot propagates the sampled parameter\n", + "uncertainty into the calculated diffraction pattern. Comparing this to\n", + "the zoomed measured-vs-calculated view helps assess whether the sampled\n", + "model family explains the data in the region of interest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.predictive(expt_name='hrpt')" + ] + }, + { + "cell_type": "markdown", + "id": "58", + "metadata": {}, + "source": [ + "A final zoomed measured-vs-calculated plot is useful for checking how\n", + "the posterior-supported model behaves in a narrow region of the pattern\n", + "after the Bayesian run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/ed-25.py b/docs/docs/tutorials/ed-25.py new file mode 100644 index 000000000..3a66d1950 --- /dev/null +++ b/docs/docs/tutorials/ed-25.py @@ -0,0 +1,349 @@ +# %% [markdown] +# # Bayesian Analysis (`emcee`): LBCO, HRPT +# +# This tutorial demonstrates a practical two-stage workflow for powder +# diffraction analysis with EasyDiffraction. +# +# In the first stage, we run a fast local refinement to obtain a sensible +# point estimate and parameter uncertainties. In the second stage, we use +# these refined values to define fit bounds and then sample the posterior +# distribution with emcee. +# +# The example uses constant-wavelength neutron powder diffraction data +# for La0.5Ba0.5CoO3 measured on HRPT at PSI. +# +# The goal is not only to obtain a good fit, but also to answer Bayesian +# questions such as: +# +# - Which parameter values are most probable? +# - How broad are the credible intervals? +# - Which parameters are strongly correlated? +# - How much uncertainty propagates into the calculated diffraction +# pattern? + +# %% [markdown] +# ## Import Library + +# %% +import easydiffraction as ed + +# %% [markdown] +# ## Step 1: Create a Project Container +# +# The project object keeps structures, experiments, fit settings, and +# plotting utilities together in a single place. We will build the full +# workflow inside this object. +# +# Save the project to a directory early on so that you can easily reload +# it later if needed. + +# %% +project = ed.Project() + +# %% +project.save_as('projects/lbco_hrpt_emcee') + +# %% [markdown] +# ## Step 2: Build the Structural Model +# +# We define a simple cubic perovskite model for LBCO. La and Ba share the +# same crystallographic site with equal occupancy, while Co and O occupy +# the remaining ideal perovskite positions. + +# %% +project.structures.create(name='lbco') + +# %% +structure = project.structures['lbco'] + +# %% +structure.space_group.name_h_m = 'P m -3 m' +structure.space_group.it_coordinate_system_code = '1' + +# %% +structure.cell.length_a = 3.88 + +# %% [markdown] +# The atom-site definitions below form the starting structural model. The +# parameters are intentionally reasonable rather than fully optimized, +# because the refinement step will improve them. + +# %% +structure.atom_sites.create( + label='La', + type_symbol='La', + fract_x=0, + fract_y=0, + fract_z=0, + wyckoff_letter='a', + adp_type='Biso', + adp_iso=0.5151, + occupancy=0.5, +) +structure.atom_sites.create( + label='Ba', + type_symbol='Ba', + fract_x=0, + fract_y=0, + fract_z=0, + wyckoff_letter='a', + adp_type='Biso', + adp_iso=0.5151, + occupancy=0.5, +) +structure.atom_sites.create( + label='Co', + type_symbol='Co', + fract_x=0.5, + fract_y=0.5, + fract_z=0.5, + wyckoff_letter='b', + adp_type='Biso', + adp_iso=0.2190, +) +structure.atom_sites.create( + label='O', + type_symbol='O', + fract_x=0, + fract_y=0.5, + fract_z=0.5, + wyckoff_letter='c', + adp_type='Biso', + adp_iso=1.3916, +) + +# %% [markdown] +# ## Step 3: Define the Diffraction Experiment +# +# Next we download the measured powder pattern, create a neutron powder +# experiment, and configure the instrument, profile, background, and +# excluded regions. + +# %% [markdown] +# Download the measured data from the repository. Alternatively, you +# could use your own data file by providing the path to it instead of +# downloading from the repository. + +# %% +data_path = ed.download_data(id=3, destination='data') + +# %% [markdown] +# Create the experiment object and specify the sample form, beam mode, +# and radiation probe. + +# %% +project.experiments.add_from_data_path( + name='hrpt', + data_path=data_path, + sample_form='powder', + beam_mode='constant wavelength', + radiation_probe='neutron', +) + +# %% +experiment = project.experiments['hrpt'] + +# %% [markdown] +# Link the structural phase to the experiment. + +# %% +experiment.linked_phases.create(id='lbco', scale=9.1351) + +# %% [markdown] +# Set instrument and peak profile parameters. +# +# These values provide the initial instrument description for the local +# refinement. Later, a subset of them will be refined. + +# %% +experiment.instrument.setup_wavelength = 1.494 +experiment.instrument.calib_twotheta_offset = 0.0 + +# %% +experiment.peak.broad_gauss_u = 0.1 +experiment.peak.broad_gauss_v = -0.1 +experiment.peak.broad_gauss_w = 0.1204 +experiment.peak.broad_lorentz_y = 0.0844 + +# %% [markdown] +# Add background points and excluded regions. +# +# The line-segment background is defined by a few anchor points. We also +# exclude regions that are not intended to contribute to the fit. + +# %% +experiment.background.create(id='1', x=10, y=168.5585) +experiment.background.create(id='2', x=30, y=164.3357) +experiment.background.create(id='3', x=50, y=166.8881) +experiment.background.create(id='4', x=110, y=175.4006) + +# %% +experiment.excluded_regions.create(id='1', start=0, end=10) +experiment.excluded_regions.create(id='2', start=100, end=180) + +# %% [markdown] +# ## Step 4: Run an Initial Local Refinement +# +# Before Bayesian sampling, it is useful to run a deterministic fit. This +# gives us: +# +# - a good point estimate near the best-fit region, +# - uncertainties from the local optimizer, +# - a quick check that the model and experiment are configured +# sensibly. +# +# In this tutorial we refine only a small set of parameters that are easy +# to interpret in the later Bayesian stage. + +# %% +structure.cell.length_a.free = True + +# %% +experiment.linked_phases['lbco'].scale.free = True +experiment.peak.broad_gauss_u.free = True +experiment.peak.broad_gauss_v.free = True +experiment.instrument.calib_twotheta_offset.free = True + +# %% [markdown] +# We keep LMFIT Levenberg-Marquardt minimizer as a fast local optimizer. +# Its main purpose here is to provide a stable starting point and +# uncertainty estimates for the Bayesian run. + +# %% +project.analysis.minimizer.show_supported() + +# %% +project.analysis.fit() + +# %% +project.display.fit.results() + +# %% [markdown] +# The correlation plot shows how strongly the fitted parameters move +# together in the local refinement. The measured-vs-calculated plots show +# how well the refined model reproduces the data globally and in a zoomed +# region. + +# %% +project.display.fit.correlations() + +# %% +project.display.pattern(expt_name='hrpt') + +# %% [markdown] +# ## Step 5: Prepare for Bayesian Sampling +# +# Bayesian samplers require finite bounds for the free parameters. Instead of +# setting them manually, we derive them from the uncertainties estimated +# in the local refinement. +# +# The helper method `set_fit_bounds_from_uncertainty` centers the bounds +# on the current parameter value and expands them by a chosen multiple of +# the reported uncertainty. +# +# The default `multiplier` is 4. If the local refinement is very tight, +# or if you expect a broader posterior, increase it explicitly. +# +# Show unset fit bounds before setting them from the local refinement uncertainties. + +# %% +project.display.parameters.free() + +# %% [markdown] +# Set fit bounds for all free parameters using the default multiplier of +# 4. In this tutorial that means the posterior pair plot will later +# refer to a `±4 × uncertainty` region in its title. To use a different +# region, pass another value, for example `multiplier=6`. + +# %% +for param in project.free_parameters: + param.set_fit_bounds_from_uncertainty() + +# %% [markdown] +# Displaying the free parameters again is a convenient way to confirm +# that the fit bounds have been assigned as expected before launching the +# sampler. + +# %% +project.display.parameters.free() + +# %% [markdown] +# ## Step 6: Configure and Run emcee +# +# We now switch from the local minimizer to the Bayesian emcee sampler. +# +# The settings below are intentionally small so the tutorial runs +# quickly. For production analysis you would usually increase the number +# of steps and often the burn-in as well. emcee also lets you tune how +# walkers are initialized, how many walkers are used, and which proposal +# move drives the ensemble. +# +# The `burn` setting is auto-resolved when left unset. Here we override +# `steps` with a smaller value to keep the tutorial fast, and the +# effective burn-in is recomputed automatically. + +# %% +project.analysis.minimizer.show_supported() + +# %% +project.analysis.minimizer.type = 'emcee' + +# %% +project.analysis.minimizer.sampling_steps = 100 # lower than the default 5000 +project.analysis.minimizer.burn_in_steps = 20 # lower than the default 1000 +project.analysis.minimizer.population_size = 16 # lower than the default 32 + +# %% +project.analysis.fit() + +# %% [markdown] +# ## Step 7: Inspect Bayesian Results +# +# The fit-results display now includes sampler settings, convergence +# diagnostics, committed parameter values, and posterior summary +# statistics. + +# %% +project.display.fit.results() + +# %% [markdown] +# The correlation and posterior-pair plots are complementary: +# +# - `plot_param_correlations` summarizes pairwise structure in a compact +# matrix. +# - `plot_posterior_pairs` shows marginal densities on the diagonal and +# posterior contours off-diagonal. In this tutorial its title also +# reminds you that the display region follows the `±4 × uncertainty` +# bounds defined above, while numeric subplot ranges are omitted to +# keep the grid readable. + +# %% +project.display.fit.correlations() + +# %% +project.display.posterior.pairs() + +# %% [markdown] +# The one-dimensional posterior distributions below make it easier to +# inspect individual parameters in isolation, including asymmetry or +# multimodality. + +# %% +project.display.posterior.distribution() + +# %% [markdown] +# Finally, the posterior predictive plot propagates the sampled parameter +# uncertainty into the calculated diffraction pattern. Comparing this to +# the zoomed measured-vs-calculated view helps assess whether the sampled +# model family explains the data in the region of interest. + +# %% +project.display.posterior.predictive(expt_name='hrpt') + +# %% [markdown] +# A final zoomed measured-vs-calculated plot is useful for checking how +# the posterior-supported model behaves in a narrow region of the pattern +# after the Bayesian run. + +# %% +project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) diff --git a/docs/docs/tutorials/ed-26.ipynb b/docs/docs/tutorials/ed-26.ipynb new file mode 100644 index 000000000..7e14253de --- /dev/null +++ b/docs/docs/tutorials/ed-26.ipynb @@ -0,0 +1,313 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "# Check whether easydiffraction is installed; install it if needed.\n", + "# Required for remote environments such as Google Colab.\n", + "import importlib.util\n", + "\n", + "if importlib.util.find_spec('easydiffraction') is None:\n", + " %pip install easydiffraction" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Bayesian Analysis Resume (`emcee`): LBCO, HRPT\n", + "\n", + "This tutorial shows how to reopen the Bayesian project created previously,\n", + "inspect the saved fit results and then run more sampling steps to\n", + "extend the existing chain. Resuming only works with EMCEE because the\n", + "current BUMPS-DREAM implementation does not support saving and\n", + "resuming its state.\n", + "\n", + "This workflow is useful when:\n", + "- the initial sampling run has not yet converged and more steps are needed,\n", + "- the initial sampling run has converged but more steps are desired\n", + " for better posterior resolution,\n", + "- the initial sampling run has converged but the posterior plots have\n", + " not yet been inspected and the user wants to see the plots before\n", + " deciding whether to run more steps.\n", + "\n", + "The workflow uses the same La0.5Ba0.5CoO3 powder diffraction example\n", + "as the DREAM Bayesian tutorial:\n", + "\n", + "- run a short local refinement,\n", + "- derive finite fit bounds for the sampled parameters,\n", + "- switch to emcee and sample the posterior,\n", + "- save the project with the emcee chain,\n", + "- resume the chain with additional steps,\n", + "- inspect posterior plots after each sampling stage." + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Import Library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import easydiffraction as ed" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Download Saved Project\n", + "\n", + "The returned path points directly to the saved project directory with\n", + "the completed Bayesian fit and persisted posterior samples and plot\n", + "caches." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "project_dir = ed.download_data(id=38, destination='projects')" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Load the Saved Bayesian Project\n", + "\n", + "Loading restores the persisted fit state, posterior samples, and plot\n", + "caches. No new fit is launched in this tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "project = ed.Project.load(project_dir)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## Review the Saved Fit Summary\n", + "\n", + "The fit summary reports the committed point estimate, sampler\n", + "settings, convergence diagnostics, and posterior parameter summaries\n", + "from the saved Bayesian run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.results()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## Show Correlations\n", + "\n", + "The correlation matrix is restored from the saved project state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.correlations()" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## Inspect Posterior Densities and Pair Structure\n", + "\n", + "The pair plot and one-dimensional posterior distributions now load\n", + "from the persisted caches generated when the Bayesian fit was saved." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.pairs()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.distribution()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## Plot Posterior Predictive Checks\n", + "\n", + "The posterior predictive view reuses the cached predictive summary\n", + "stored in the project rather than recalculating it on first display.\n", + "It overlays the 95% credible interval propagated from the posterior\n", + "samples." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.predictive(expt_name='hrpt')" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "A zoomed view is useful for checking the propagated uncertainty in a\n", + "narrow region of the diffraction pattern." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## Resume emcee Sampling\n", + "\n", + "Resume from the saved backend and append 100 more emcee steps to the\n", + "existing chain. We use only 100 steps here to keep the tutorial fast,\n", + "but in practice you would typically run more steps to ensure\n", + "convergence and better posterior resolution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "project.analysis.fit(resume=True, extra_steps=100)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.fit.results()" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Inspect the Resumed Posterior\n", + "\n", + "After resume, the posterior plots use the extended chain." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.pairs()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.distribution()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/ed-26.py b/docs/docs/tutorials/ed-26.py new file mode 100644 index 000000000..2d72379df --- /dev/null +++ b/docs/docs/tutorials/ed-26.py @@ -0,0 +1,127 @@ +# %% [markdown] +# # Bayesian Analysis Resume (`emcee`): LBCO, HRPT +# +# This tutorial shows how to reopen the Bayesian project created previously, +# inspect the saved fit results and then run more sampling steps to +# extend the existing chain. Resuming only works with EMCEE because the +# current BUMPS-DREAM implementation does not support saving and +# resuming its state. +# +# This workflow is useful when: +# - the initial sampling run has not yet converged and more steps are needed, +# - the initial sampling run has converged but more steps are desired +# for better posterior resolution, +# - the initial sampling run has converged but the posterior plots have +# not yet been inspected and the user wants to see the plots before +# deciding whether to run more steps. +# +# The workflow uses the same La0.5Ba0.5CoO3 powder diffraction example +# as the DREAM Bayesian tutorial: +# +# - run a short local refinement, +# - derive finite fit bounds for the sampled parameters, +# - switch to emcee and sample the posterior, +# - save the project with the emcee chain, +# - resume the chain with additional steps, +# - inspect posterior plots after each sampling stage. + +# %% [markdown] +# ## Import Library + +# %% +import easydiffraction as ed + +# %% [markdown] +# ## Download Saved Project +# +# The returned path points directly to the saved project directory with +# the completed Bayesian fit and persisted posterior samples and plot +# caches. + +# %% +project_dir = ed.download_data(id=38, destination='projects') + +# %% [markdown] +# ## Load the Saved Bayesian Project +# +# Loading restores the persisted fit state, posterior samples, and plot +# caches. No new fit is launched in this tutorial. + +# %% +project = ed.Project.load(project_dir) + +# %% [markdown] +# ## Review the Saved Fit Summary +# +# The fit summary reports the committed point estimate, sampler +# settings, convergence diagnostics, and posterior parameter summaries +# from the saved Bayesian run. + +# %% +project.display.fit.results() + +# %% [markdown] +# ## Show Correlations +# +# The correlation matrix is restored from the saved project state. + +# %% +project.display.fit.correlations() + +# %% [markdown] +# ## Inspect Posterior Densities and Pair Structure +# +# The pair plot and one-dimensional posterior distributions now load +# from the persisted caches generated when the Bayesian fit was saved. + +# %% +project.display.posterior.pairs() + +# %% +project.display.posterior.distribution() + +# %% [markdown] +# ## Plot Posterior Predictive Checks +# +# The posterior predictive view reuses the cached predictive summary +# stored in the project rather than recalculating it on first display. +# It overlays the 95% credible interval propagated from the posterior +# samples. + +# %% +project.display.posterior.predictive(expt_name='hrpt') + +# %% [markdown] +# A zoomed view is useful for checking the propagated uncertainty in a +# narrow region of the diffraction pattern. + +# %% +project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) + +# %% [markdown] +# ## Resume emcee Sampling +# +# Resume from the saved backend and append 100 more emcee steps to the +# existing chain. We use only 100 steps here to keep the tutorial fast, +# but in practice you would typically run more steps to ensure +# convergence and better posterior resolution. + +# %% +project.analysis.fit(resume=True, extra_steps=100) + +# %% +project.display.fit.results() + +# %% [markdown] +# ## Inspect the Resumed Posterior +# +# After resume, the posterior plots use the extended chain. + +# %% +project.display.posterior.pairs() + +# %% +project.display.posterior.distribution() + +# %% +project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb index 5dd0e7a06..6971955d7 100644 --- a/docs/docs/tutorials/ed-5.ipynb +++ b/docs/docs/tutorials/ed-5.ipynb @@ -371,9 +371,19 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "29", "metadata": {}, + "outputs": [], + "source": [ + "project.save_as('projects/cosio_d20')" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, "source": [ "#### Add Structure" ] @@ -381,7 +391,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +400,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "32", "metadata": {}, "source": [ "#### Add Experiment" @@ -399,7 +409,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -408,7 +418,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "34", "metadata": {}, "source": [ "## Perform Analysis\n", @@ -422,7 +432,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -432,7 +442,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -441,7 +451,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "37", "metadata": {}, "source": [ "#### Set Free Parameters" @@ -450,7 +460,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -473,7 +483,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -494,7 +504,7 @@ }, { "cell_type": "markdown", - "id": "39", + "id": "40", "metadata": {}, "source": [ "Show free parameters after selection." @@ -503,7 +513,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -512,7 +522,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "42", "metadata": {}, "source": [ "#### Set Constraints\n", @@ -523,7 +533,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "43", "metadata": {}, "outputs": [], "source": [ @@ -539,7 +549,7 @@ }, { "cell_type": "markdown", - "id": "43", + "id": "44", "metadata": {}, "source": [ "Set constraints." @@ -548,7 +558,7 @@ { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "45", "metadata": { "lines_to_next_cell": 2 }, @@ -559,7 +569,7 @@ }, { "cell_type": "markdown", - "id": "45", + "id": "46", "metadata": {}, "source": [ "#### Run Fitting" @@ -568,7 +578,7 @@ { "cell_type": "code", "execution_count": null, - "id": "46", + "id": "47", "metadata": {}, "outputs": [], "source": [ @@ -578,7 +588,7 @@ { "cell_type": "code", "execution_count": null, - "id": "47", + "id": "48", "metadata": {}, "outputs": [], "source": [ @@ -588,7 +598,7 @@ { "cell_type": "code", "execution_count": null, - "id": "48", + "id": "49", "metadata": {}, "outputs": [], "source": [ @@ -597,7 +607,7 @@ }, { "cell_type": "markdown", - "id": "49", + "id": "50", "metadata": {}, "source": [ "#### Plot Measured vs Calculated" @@ -606,7 +616,7 @@ { "cell_type": "code", "execution_count": null, - "id": "50", + "id": "51", "metadata": {}, "outputs": [], "source": [ @@ -616,7 +626,7 @@ { "cell_type": "code", "execution_count": null, - "id": "51", + "id": "52", "metadata": {}, "outputs": [], "source": [ @@ -625,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "52", + "id": "53", "metadata": {}, "source": [ "## Summary\n", @@ -635,7 +645,7 @@ }, { "cell_type": "markdown", - "id": "53", + "id": "54", "metadata": {}, "source": [ "#### Show Project Summary" @@ -644,7 +654,7 @@ { "cell_type": "code", "execution_count": null, - "id": "54", + "id": "55", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py index 7c307b1bd..cbd235664 100644 --- a/docs/docs/tutorials/ed-5.py +++ b/docs/docs/tutorials/ed-5.py @@ -179,6 +179,9 @@ # %% project = Project() +# %% +project.save_as('projects/cosio_d20') + # %% [markdown] # #### Add Structure diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index b789645d0..7bca1cdc4 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -46,10 +46,11 @@ The tutorials are organized into the following categories: - [Co2SiO4 Sequential Fit](ed-23.ipynb) – Resumes a sequential refinement from an existing `analysis/results.csv` after an incomplete previous run. -- [LBCO Bayesian Display](ed-24.ipynb) – Shows how to load the saved - project after a Bayesian analysis and inspect the persisted fit - summary, correlation matrix, posterior plots, and predictive checks - without rerunning MCMC sampling. + +See also under [Bayesian Analysis](#bayesian-analysis): +[LBCO Bayesian Display (`bumps-dream`)](ed-24.ipynb) and +[LBCO Bayesian Resume (`emcee`)](ed-26.ipynb) — both load saved projects +containing Bayesian fit state. ## Powder Diffraction @@ -76,7 +77,7 @@ The tutorials are organized into the following categories: refinement of Taurine using time-of-flight neutron single crystal diffraction data from SENJU at J-PARC. -## Pair Distribution Function (PDF) +## Pair Distribution Function - [Ni `pd-neut-cwl`](ed-10.ipynb) – Demonstrates a PDF analysis of Ni using data collected from a constant wavelength neutron powder @@ -87,7 +88,7 @@ The tutorials are organized into the following categories: - [NaCl `pd-xray`](ed-12.ipynb) – Demonstrates a PDF analysis of NaCl using data collected from an X-ray powder diffraction experiment. -## Multi-Structure & Multi-Experiment Refinement +## Multiple Data Blocks - [PbSO4 NPD+XRD](ed-4.ipynb) – Joint fit of PbSO4 using X-ray and neutron constant wavelength powder diffraction data. @@ -109,17 +110,34 @@ The tutorials are organized into the following categories: ## Bayesian Analysis -- [LBCO Bayesian](ed-21.ipynb) – Demonstrates how to perform a Bayesian - analysis of the La0.5Ba0.5CoO3 crystal structure using constant - wavelength neutron powder diffraction data from HRPT at PSI. This - tutorial covers the use of Markov Chain Monte Carlo (MCMC) sampling to - explore the posterior distribution of the refined parameters, - providing insights into parameter uncertainties and correlations. -- [Tb2TiO7 Bayesian](ed-22.ipynb) – Another example of a Bayesian - analysis. This tutorial focuses on the Tb2TiO7 crystal structure using +- [LBCO Bayesian (`bumps-dream`)](ed-21.ipynb) – Demonstrates how to + perform a Bayesian analysis of the La0.5Ba0.5CoO3 crystal structure + using constant wavelength neutron powder diffraction data from HRPT at + PSI. Covers the use of Markov Chain Monte Carlo (MCMC) sampling with + the bumps-DREAM minimizer to explore the posterior distribution of the + refined parameters, providing insights into parameter uncertainties + and correlations. +- [LBCO Bayesian Display (`bumps-dream`)](ed-24.ipynb) – Shows how to + reopen the saved Bayesian project produced by the LBCO Bayesian + tutorial and inspect persisted fit summaries, correlation matrix, + posterior distribution plots, and predictive checks — without + rerunning MCMC sampling. +- [LBCO Bayesian (`emcee`)](ed-25.ipynb) – Two-stage workflow on the + LBCO HRPT dataset: first a quick local refinement to obtain a point + estimate and uncertainties, then full posterior sampling with the + emcee minimizer. Covers credible intervals, parameter correlations, + and propagation of uncertainty into the calculated diffraction + pattern. +- [LBCO Bayesian Resume (`emcee`)](ed-26.ipynb) – Loads a Bayesian + project that already contains an emcee chain, inspects the posterior, + and resumes sampling with additional steps. The full project state + (parameters, chain, plot caches) round-trips through disk. Resuming is + currently supported only for emcee, not for bumps-DREAM. +- [Tb2TiO7 Bayesian (`emcee`)](ed-22.ipynb) – Another example of a + Bayesian analysis, focused on the Tb2TiO7 crystal structure using constant wavelength neutron single crystal diffraction data from HEiDi - at FRM II. Similar to the LBCO Bayesian tutorial, it covers the use of - MCMC sampling to explore the posterior distribution of the refined + at FRM II. Similar to the LBCO Bayesian tutorial, it covers MCMC + sampling to explore the posterior distribution of the refined parameters, providing insights into parameter uncertainties and correlations in the context of single crystal diffraction data. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 02f862023..15e33ceb9 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -199,7 +199,6 @@ nav: - Load Project: - LBCO Single: tutorials/ed-18.ipynb - Co2SiO4 Sequential: tutorials/ed-23.ipynb - - LBCO Bayesian: tutorials/ed-24.ipynb - Powder Diffraction: - Co2SiO4 pd-neut-cwl: tutorials/ed-5.ipynb - HS pd-neut-cwl: tutorials/ed-6.ipynb @@ -220,8 +219,11 @@ nav: - LBCO+Si McStas: tutorials/ed-9.ipynb - BEER McStas: tutorials/ed-20.ipynb - Bayesian Analysis: - - LBCO Bayesian: tutorials/ed-21.ipynb - - Tb2TiO7 Bayesian: tutorials/ed-22.ipynb + - LBCO pd bumps-dream: tutorials/ed-21.ipynb + - LBCO pd bumps-dream Display: tutorials/ed-24.ipynb + - LBCO pd emcee: tutorials/ed-25.ipynb + - LBCO pd emcee Resume: tutorials/ed-26.ipynb + - Tb2TiO7 sg bumps-dream: tutorials/ed-22.ipynb - Workshops & Schools: - DMSC Summer School: tutorials/ed-13.ipynb - Command-Line: diff --git a/pixi.lock b/pixi.lock index e17dcaa8c..d69e22de0 100644 --- a/pixi.lock +++ b/pixi.lock @@ -96,7 +96,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -155,7 +155,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -178,7 +178,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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 @@ -186,7 +185,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -201,9 +199,8 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -230,8 +227,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl @@ -250,7 +245,6 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -301,7 +295,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -318,6 +311,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -355,7 +349,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -414,7 +408,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -488,14 +482,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl @@ -511,7 +503,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl @@ -530,7 +521,6 @@ environments: - 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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl @@ -540,9 +530,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl @@ -560,7 +548,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -612,9 +599,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.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 @@ -627,6 +614,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -664,7 +652,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -721,7 +709,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -790,14 +778,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/53/b50773ecf1d1e4a5858ee13011e30317ba02639ae4a1411a34967951fc9b/crysfml-0.6.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -811,7 +797,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl @@ -838,8 +823,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl @@ -851,6 +834,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-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/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl @@ -859,7 +843,6 @@ environments: - 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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl @@ -904,7 +887,6 @@ 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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl @@ -912,7 +894,6 @@ environments: - 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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl @@ -928,6 +909,7 @@ environments: - 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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -1027,7 +1009,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -1086,7 +1068,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -1108,7 +1090,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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 @@ -1116,7 +1097,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -1130,8 +1110,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -1158,10 +1136,8 @@ environments: - 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/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl @@ -1182,7 +1158,7 @@ environments: - 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/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -1234,7 +1210,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -1250,6 +1225,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1285,7 +1261,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -1344,7 +1320,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -1418,7 +1394,6 @@ environments: - 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/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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 @@ -1427,7 +1402,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/1d/9f9e30d76300b0150afaa8b37fab9a0194d44fd4f6b1e5038aca4a1440ed/crysfml-0.6.2-cp312-cp312-macosx_14_0_arm64.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/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -1444,7 +1418,6 @@ environments: - 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/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/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/a0/37fb236da6040e337381dd656cafb97d09eacb998c5db3057547f5ffddd9/pycifrw-5.0.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -1461,7 +1434,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/3f/d0/7b958df957e4827837b590944008f0b28078f552b451f7407b4b3d54f574/asciichartpy-1.5.25-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-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/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-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 @@ -1472,8 +1444,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl @@ -1490,7 +1460,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -1528,6 +1497,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.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/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 @@ -1545,7 +1515,6 @@ environments: - 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/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -1558,6 +1527,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1593,7 +1563,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -1650,7 +1620,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -1720,14 +1690,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/c7/78200c18404ded028758b28b588aa1f4f3acd851271a74156a2a3db9eadf/crysfml-0.6.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl @@ -1743,7 +1711,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl @@ -1760,7 +1727,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-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/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-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/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl @@ -1770,8 +1736,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/6f/0c/8297c8d978c919ad6318011631a6123082d5da940da5f8612e75a247d739/diffpy_pdffit2-1.6.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl @@ -1792,7 +1756,6 @@ environments: - 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/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl @@ -1825,6 +1788,7 @@ environments: - 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/b4/5e/bf11645aebb9af7d8d35927c40d3855816a0855c799e8156eeca8d632c90/diffpy_structure-3.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl @@ -1846,7 +1810,6 @@ environments: - 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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -1860,6 +1823,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1955,7 +1919,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -2014,7 +1978,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -2037,7 +2001,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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 @@ -2045,7 +2008,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -2060,9 +2022,8 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -2089,8 +2050,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl @@ -2109,7 +2068,6 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -2160,7 +2118,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -2177,6 +2134,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -2214,7 +2172,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -2273,7 +2231,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -2347,14 +2305,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/06/41/4e70dea1d0311016c0b0b1c53a24a266f9f8a34c6bc1af0f17cfca20aa1d/gemmi-0.7.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl @@ -2370,7 +2326,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl @@ -2389,7 +2344,6 @@ environments: - 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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl @@ -2399,9 +2353,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-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/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.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/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl @@ -2419,7 +2371,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl @@ -2471,9 +2422,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.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 @@ -2486,6 +2437,7 @@ environments: - 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/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/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -2523,7 +2475,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -2580,7 +2532,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -2649,14 +2601,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/f9/f15c95d6b200167cb22c5eca5eecfa9d28a8ee3f74095f1cd2345c71f2f9/pydoclint-0.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/53/b50773ecf1d1e4a5858ee13011e30317ba02639ae4a1411a34967951fc9b/crysfml-0.6.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/12/bbce9472f489cb5c4c23b0d13e5c59c37c1aab11b7ac637dfe6bbdccebe7/copier-9.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/1f/d398de1612f7a611e22d743280339c9af4903675635e41be3370091c704b/arviz_stats-1.1.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/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-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/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -2670,7 +2620,6 @@ environments: - 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/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/b2/986d1220f6ee931e338d272bc1f3ec02cfe5f9b5fad84e95afdad57f1ebc/format_docstring-0.2.7-py3-none-any.whl @@ -2697,8 +2646,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/2b/e260d50e64690d2a9e405d52ccd18a63c286c5088937dd0107cb23eb3195/diffpy_utils-3.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/64/a8/c09fbe44b12fa919c5bfe0afb71e60d1231a7dc93405e54c30496c57c9d3/arviz-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/d1/705e6c19b437a4105bf3b9ae7945fcfc3ad2abb73d14bae0a3f2d58b305b/arviz_base-1.1.0-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/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl @@ -2710,6 +2657,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-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/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.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/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl @@ -2718,7 +2666,6 @@ environments: - 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/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl @@ -2763,7 +2710,6 @@ 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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl @@ -2771,7 +2717,6 @@ environments: - 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/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-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/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl @@ -2787,6 +2732,7 @@ environments: - 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/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -2868,7 +2814,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -2927,7 +2873,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -2956,9 +2902,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.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/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/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2f/c8/005d1de3af80f54411703d1263a0b9d31276411ec9f273d9432c59b17299/arviz_plots-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -3014,6 +2960,7 @@ environments: - 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/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl osx-arm64: @@ -3049,7 +2996,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh5552912_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -3108,7 +3055,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -3180,7 +3127,6 @@ environments: - 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/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/e0/f1871f520c359e4e3a2eb7437c9e7e792bb6c356414e8617937561167caf/pycifrw-5.0.1.tar.gz - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl @@ -3221,11 +3167,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.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/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.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/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl win-64: @@ -3261,7 +3209,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-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.2.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.13.0-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda @@ -3318,7 +3266,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-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.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-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 @@ -3404,6 +3352,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/78/3c/2a612b95ddbb9a6bdcb47b7a93c4884f74c6ff22356b2f7b213b16e65c35/pycifstar-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/7b/67/b1944235474aac3f0b0e1b232ce49547f9f9461ca4b943df1b88da5d3f1d/bumps-1.0.4-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/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/d0/26c81ffbe588f936d05f395da34046c66322e8067c9fd331c788c4f682f2/diffpy_pdffit2-1.6.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl @@ -3430,7 +3379,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-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/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl @@ -3438,6 +3386,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-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/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/1c/a28b27effb13a381fe077ea3e3e78f6debd6315f2b3edff67bbb93d0ef51/gemmi-0.7.5-cp314-cp314-win_amd64.whl @@ -3989,6 +3938,7 @@ packages: - libgcc >=14 - __glibc >=2.17,<3.0.a0 license: MIT + license_family: MIT purls: [] size: 419935 timestamp: 1779396012261 @@ -4104,6 +4054,7 @@ packages: - libnghttp2 >=1.68.1,<2.0a0 - openssl >=3.5.6,<4.0a0 license: MIT + license_family: MIT purls: [] size: 19707853 timestamp: 1779471099457 @@ -4245,6 +4196,7 @@ packages: - cpython >=3.12 - zeromq >=4.3.5,<4.4.0a0 license: BSD-3-Clause + license_family: BSD purls: - pkg:pypi/pyzmq?source=hash-mapping size: 210896 @@ -4796,19 +4748,18 @@ packages: - pkg:pypi/idna?source=compressed-mapping size: 62642 timestamp: 1779294335905 -- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda - sha256: 82ab2a0d91ca1e7e63ab6a4939356667ef683905dea631bc2121aa534d347b16 - md5: 080594bf4493e6bae2607e65390c520a +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + sha256: 43e2a5497cad1598ff88a3e69f69bc88b7b8f141fa63c60eab5db296317318b8 + md5: ffc17e785d64e12fc311af9184221839 depends: - python >=3.10 - zipp >=3.20 - python license: Apache-2.0 - license_family: APACHE purls: - - pkg:pypi/importlib-metadata?source=hash-mapping - size: 34387 - timestamp: 1773931568510 + - pkg:pypi/importlib-metadata?source=compressed-mapping + size: 34766 + timestamp: 1779714582554 - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyh5552912_1.conda sha256: 5c1f3e874adaf603449f2b135d48f168c5d510088c78c229bda0431268b43b27 md5: 4b53d436f3fbc02ce3eeaf8ae9bebe01 @@ -5793,17 +5744,16 @@ packages: - pkg:pypi/sniffio?source=hash-mapping size: 15698 timestamp: 1762941572482 -- conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.3-pyhd8ed1ab_0.conda - sha256: 23b71ecf089967d2900126920e7f9ff18cdcef82dbff3e2f54ffa360243a17ac - md5: 18de09b20462742fe093ba39185d9bac +- conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + sha256: 2afa5fe9331c09b4c4689ddf6ace8fc16c837eae547c57dab325b844072fdd77 + md5: 9e21f087f087f805debe877d88e00a14 depends: - python >=3.10 license: MIT - license_family: MIT purls: - - pkg:pypi/soupsieve?source=hash-mapping - size: 38187 - timestamp: 1769034509657 + - pkg:pypi/soupsieve?source=compressed-mapping + size: 38802 + timestamp: 1779635534390 - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda sha256: 570da295d421661af487f1595045760526964f41471021056e993e73089e9c41 md5: b1b505328da7a6b246787df4b5a49fbc @@ -6468,6 +6418,7 @@ packages: depends: - __osx >=11.0 license: MIT + license_family: MIT purls: [] size: 122732 timestamp: 1779396113397 @@ -6585,6 +6536,7 @@ packages: - icu >=78.3,<79.0a0 - libsqlite >=3.53.1,<4.0a0 license: MIT + license_family: MIT purls: [] size: 17981016 timestamp: 1779471179908 @@ -6780,6 +6732,7 @@ packages: - _python_abi3_support 1.* - cpython >=3.12 license: BSD-3-Clause + license_family: BSD purls: - pkg:pypi/pyzmq?source=compressed-mapping size: 191432 @@ -7372,6 +7325,7 @@ packages: sha256: 0fad158aaffdb78d3a386e9e078e9cf17f27614750ab5e148d47867bf7c3ee91 md5: d9b8ee334a3a6285cfc991c80edb3e13 license: MIT + license_family: MIT purls: [] size: 32513682 timestamp: 1779471184734 @@ -7580,6 +7534,7 @@ packages: - cpython >=3.12 - zeromq >=4.3.5,<4.3.6.0a0 license: BSD-3-Clause + license_family: BSD purls: - pkg:pypi/pyzmq?source=compressed-mapping size: 182831 @@ -7768,7 +7723,6 @@ packages: - pypi: . name: easydiffraction requires_dist: - - arviz - asciichartpy - asteval - bumps @@ -7778,6 +7732,7 @@ packages: - dfo-ls - diffpy-pdffit2 - diffpy-utils + - emcee - gemmi - h5py - lmfit @@ -8576,82 +8531,6 @@ packages: version: 0.5.2 sha256: e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: msgpack version: 1.1.2 @@ -8695,6 +8574,44 @@ packages: - psutil ; extra == 'test' - setuptools ; extra == 'test' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: sqlalchemy + version: 2.0.50 + sha256: 6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl name: mkdocstrings-python version: 2.0.3 @@ -9130,87 +9047,11 @@ packages: requires_dist: - numpy requires_python: '>=3.11,<3.15' -- pypi: https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' - 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/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl name: scipy version: 1.17.1 @@ -9308,44 +9149,6 @@ packages: - pytest-benchmark ; extra == 'testing' - coverage ; extra == 'testing' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl name: ruff version: 0.15.14 @@ -10046,6 +9849,44 @@ packages: - pyyaml - pygments>=2.19.1 ; extra == 'extra' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl + name: sqlalchemy + version: 2.0.50 + sha256: 15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl name: multidict version: 6.7.1 @@ -10160,6 +10001,44 @@ packages: requires_dist: - typing-extensions>=4.14.1 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: sqlalchemy + version: 2.0.50 + sha256: 110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl name: lazy-loader version: '0.5' @@ -10846,6 +10725,44 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl + name: sqlalchemy + version: 2.0.50 + sha256: 9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl name: matplotlib version: 3.10.9 @@ -11067,6 +10984,44 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl + name: sqlalchemy + version: 2.0.50 + sha256: 23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-py3-none-any.whl name: docstring-parser-fork version: 0.0.14 @@ -11331,44 +11286,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl - name: sqlalchemy - version: 2.0.49 - sha256: 77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 - requires_dist: - - importlib-metadata ; python_full_version < '3.8' - - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' - - typing-extensions>=4.6.0 - - greenlet>=1 ; extra == 'asyncio' - - mypy>=0.910 ; extra == 'mypy' - - pyodbc ; extra == 'mssql' - - pymssql ; extra == 'mssql-pymssql' - - pyodbc ; extra == 'mssql-pyodbc' - - mysqlclient>=1.4.0 ; extra == 'mysql' - - mysql-connector-python ; extra == 'mysql-connector' - - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' - - cx-oracle>=8 ; extra == 'oracle' - - oracledb>=1.0.1 ; extra == 'oracle-oracledb' - - psycopg2>=2.7 ; extra == 'postgresql' - - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' - - greenlet>=1 ; extra == 'postgresql-asyncpg' - - asyncpg ; extra == 'postgresql-asyncpg' - - psycopg2-binary ; extra == 'postgresql-psycopg2binary' - - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' - - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' - - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' - - pymysql ; extra == 'pymysql' - - greenlet>=1 ; extra == 'aiomysql' - - aiomysql>=0.2.0 ; extra == 'aiomysql' - - greenlet>=1 ; extra == 'aioodbc' - - aioodbc ; extra == 'aioodbc' - - greenlet>=1 ; extra == 'asyncmy' - - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' - - greenlet>=1 ; extra == 'aiosqlite' - - aiosqlite ; extra == 'aiosqlite' - - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' - - sqlcipher3-binary ; extra == 'sqlcipher' - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl name: scipy version: 1.17.1 @@ -11633,6 +11550,44 @@ packages: - mkdocs-section-index ; extra == 'docs' - mkdocs-literate-nav ; extra == 'docs' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl + name: sqlalchemy + version: 2.0.50 + sha256: c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 + requires_dist: + - importlib-metadata ; python_full_version < '3.8' + - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' + - typing-extensions>=4.6.0 + - greenlet>=1 ; extra == 'asyncio' + - mypy>=0.910 ; extra == 'mypy' + - pyodbc ; extra == 'mssql' + - pymssql ; extra == 'mssql-pymssql' + - pyodbc ; extra == 'mssql-pyodbc' + - mysqlclient>=1.4.0 ; extra == 'mysql' + - mysql-connector-python ; extra == 'mysql-connector' + - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector' + - cx-oracle>=8 ; extra == 'oracle' + - oracledb>=1.0.1 ; extra == 'oracle-oracledb' + - psycopg2>=2.7 ; extra == 'postgresql' + - pg8000>=1.29.1 ; extra == 'postgresql-pg8000' + - greenlet>=1 ; extra == 'postgresql-asyncpg' + - asyncpg ; extra == 'postgresql-asyncpg' + - psycopg2-binary ; extra == 'postgresql-psycopg2binary' + - psycopg2cffi ; extra == 'postgresql-psycopg2cffi' + - psycopg>=3.0.7 ; extra == 'postgresql-psycopg' + - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary' + - pymysql ; extra == 'pymysql' + - greenlet>=1 ; extra == 'aiomysql' + - aiomysql>=0.2.0 ; extra == 'aiomysql' + - greenlet>=1 ; extra == 'aioodbc' + - aioodbc ; extra == 'aioodbc' + - greenlet>=1 ; extra == 'asyncmy' + - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy' + - greenlet>=1 ; extra == 'aiosqlite' + - aiosqlite ; extra == 'aiosqlite' + - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite' + - sqlcipher3-binary ; extra == 'sqlcipher' + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl name: numpy version: 2.4.6 @@ -11798,6 +11753,17 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl + name: emcee + version: 3.1.6 + sha256: f2d63752023bdccf744461450e512a5b417ae7d28f18e12acd76a33de87580cb + requires_dist: + - numpy + - h5py ; extra == 'extras' + - scipy ; extra == 'extras' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - coverage[toml] ; extra == 'tests' - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl name: dunamai version: 1.26.1 diff --git a/pixi.toml b/pixi.toml index 70a8a310e..c6605061f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -31,6 +31,9 @@ macos = '14.0' #libc = { family = 'glibc', version = '2.35' } libc = '2.35' +[pypi-dependencies] +emcee = '>=3.1' + # Non-default features: # Set specific Python versions to be used in CI testing. diff --git a/pyproject.toml b/pyproject.toml index d953cfa19..de4f24894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ 'sympy', # Symbolic mathematics library 'lmfit', # Non-linear optimization and curve fitting 'bumps', # Non-linear optimization and curve fitting + 'emcee', # Affine-invariant MCMC sampler 'dfo-ls', # Non-linear optimization and curve fitting 'gemmi', # Crystallography library 'cryspy', # Calculations of diffraction patterns @@ -46,7 +47,6 @@ dependencies = [ 'darkdetect', # Detecting dark mode (system-level) 'pandas', # Displaying tables in Jupyter notebooks 'plotly', # Interactive plots - 'arviz', # Bayesian analysis summaries and posterior plotting 'py3Dmol', # Visualisation of crystal structures ] diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 9a01d8385..004fc6f9a 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING +import h5py import numpy as np import pandas as pd @@ -29,11 +30,16 @@ from easydiffraction.analysis.enums import FitCorrelationSourceEnum from easydiffraction.analysis.enums import FitModeEnum from easydiffraction.analysis.enums import FitResultKindEnum +from easydiffraction.analysis.fit_helpers.bayesian import ESS_BULK_CONVERGENCE_THRESHOLD +from easydiffraction.analysis.fit_helpers.bayesian import R_HAT_CONVERGENCE_THRESHOLD from easydiffraction.analysis.fit_helpers.bayesian import BayesianFitResults from easydiffraction.analysis.fit_helpers.bayesian import PosteriorPredictiveSummary from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples +from easydiffraction.analysis.fit_helpers.bayesian import posterior_predictive_cache_key from easydiffraction.analysis.fit_helpers.reporting import FitResults from easydiffraction.analysis.fitting import Fitter +from easydiffraction.analysis.fitting import FitterFitOptions +from easydiffraction.analysis.minimizers.emcee import EMCEE_CHAIN_GROUP from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum from easydiffraction.core.category_owner import CategoryOwner from easydiffraction.core.guard import _apply_help_filter @@ -43,6 +49,7 @@ from easydiffraction.core.variable import StringDescriptor from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.display.progress import make_display_handle +from easydiffraction.display.progress import notebook_fit_stop_control from easydiffraction.display.tables import TableRenderer from easydiffraction.io.cif.serialize import analysis_to_cif from easydiffraction.utils.enums import VerbosityEnum @@ -50,6 +57,7 @@ from easydiffraction.utils.logging import log from easydiffraction.utils.utils import _help_method_rows from easydiffraction.utils.utils import _help_property_rows +from easydiffraction.utils.utils import format_bulleted_warning from easydiffraction.utils.utils import render_cif from easydiffraction.utils.utils import render_object_help from easydiffraction.utils.utils import render_table @@ -524,17 +532,6 @@ def _swap_fitting_mode(self, new_type: str) -> None: """Switch the active fitting-mode category.""" self._replace_fitting_mode(new_type, announce=True) - @staticmethod - def _predictive_cache_key( - experiment_name: str, - x_axis_name: str, - *, - include_draws: bool = True, - ) -> str: - """Return the runtime cache key for one predictive summary.""" - key_suffix = 'draws' if include_draws else 'band' - return f'{experiment_name}:{x_axis_name}:{key_suffix}' - def _live_parameter_map(self) -> dict[str, Parameter]: """Return live parameters keyed by unique name.""" all_parameters = self.project.structures.parameters + self.project.experiments.parameters @@ -668,7 +665,7 @@ def _restored_predictive_summaries(self) -> dict[str, PosteriorPredictiveSummary ) restored_predictive[experiment_name] = summary restored_predictive[ - self._predictive_cache_key( + posterior_predictive_cache_key( experiment_name, x_axis_name, include_draws=False, @@ -676,7 +673,7 @@ def _restored_predictive_summaries(self) -> dict[str, PosteriorPredictiveSummary ] = summary if summary.draws is not None: restored_predictive[ - self._predictive_cache_key( + posterior_predictive_cache_key( experiment_name, x_axis_name, include_draws=True, @@ -684,6 +681,135 @@ def _restored_predictive_summaries(self) -> dict[str, PosteriorPredictiveSummary ] = summary return restored_predictive + @staticmethod + def _finite_float(value: object) -> float | None: + """Return a finite float or ``None``.""" + if value is None: + return None + try: + numeric_value = float(value) + except (TypeError, ValueError): + return None + return numeric_value if np.isfinite(numeric_value) else None + + @classmethod + def _restored_bayesian_converged( + cls, + *, + max_r_hat: object, + min_ess_bulk: object, + ) -> bool: + """Return restored convergence status.""" + r_hat = cls._finite_float(max_r_hat) + ess_bulk = cls._finite_float(min_ess_bulk) + if r_hat is None or ess_bulk is None: + return False + return r_hat <= R_HAT_CONVERGENCE_THRESHOLD and ess_bulk >= ESS_BULK_CONVERGENCE_THRESHOLD + + def _restored_bayesian_convergence_diagnostics( + self, + *, + sample_shape: tuple[int, int, int], + n_parameters: int, + ) -> dict[str, object]: + """Return restored convergence diagnostics.""" + max_r_hat = self.fit_result.gelman_rubin_max.value + min_ess_bulk = self.fit_result.effective_sample_size_min.value + diagnostics: dict[str, object] = { + 'converged': self._restored_bayesian_converged( + max_r_hat=max_r_hat, + min_ess_bulk=min_ess_bulk, + ), + 'max_r_hat': max_r_hat, + 'min_ess_bulk': min_ess_bulk, + 'n_draws': int(sample_shape[0]), + 'n_chains': int(sample_shape[1]), + 'n_parameters': int(n_parameters), + } + + acceptance_rate_mean = self.fit_result.acceptance_rate_mean.value + if acceptance_rate_mean is not None: + diagnostics['acceptance_rate_mean'] = acceptance_rate_mean + return diagnostics + + def _restored_bayesian_reduced_chi_square( + self, + value: object, + *, + restored_parameters: list[Parameter], + ) -> float | None: + """Return restored Bayesian reduced chi-square.""" + persisted_value = self._finite_float(value) + if persisted_value is not None: + return persisted_value + + best_log_posterior = self._finite_float(self.fit_result.best_log_posterior.value) + if best_log_posterior is None: + return None + + n_data_points = self._fit_data_point_count(self.project.experiments) + degrees_of_freedom = n_data_points - len(restored_parameters) + if degrees_of_freedom <= 0: + return None + return -2.0 * best_log_posterior / degrees_of_freedom + + def _restore_bayesian_fit_results_from_projection( + self, + *, + restored_parameters: list[Parameter], + fitting_time: float | None, + reduced_chi_square: float | None, + ) -> BayesianFitResults: + """Rebuild a Bayesian runtime result from saved state.""" + posterior_samples = self._restored_posterior_samples() + sample_shape = ( + np.asarray(posterior_samples.parameter_samples).shape + if posterior_samples is not None + else (0, 0, 0) + ) + posterior_summaries = self._restored_posterior_summaries() + n_parameters = int(sample_shape[2]) or len(posterior_summaries) + sampler_settings = self.minimizer._native_kwargs() + resolved_random_seed = self._restored_bayesian_random_seed(sampler_settings) + sampler_name = ( + 'dream' + if self.minimizer.type == MinimizerTypeEnum.BUMPS_DREAM.value + else str(self.minimizer.type) + ) + restored_results = BayesianFitResults( + success=bool(self.fit_result.success.value), + parameters=restored_parameters, + reduced_chi_square=self._restored_bayesian_reduced_chi_square( + reduced_chi_square, + restored_parameters=restored_parameters, + ), + starting_parameters=list(restored_parameters), + fitting_time=fitting_time, + sampler_name=sampler_name, + point_estimate_name=self.fit_result.point_estimate_name.value or 'best_sample', + posterior_samples=posterior_samples, + posterior_parameter_summaries=posterior_summaries, + posterior_predictive=self._restored_predictive_summaries(), + credible_interval_levels=( + float(self.fit_result.credible_interval_inner.value), + float(self.fit_result.credible_interval_outer.value), + ), + sampler_settings=self._restored_bayesian_sampler_settings( + sampler_settings, + random_seed=resolved_random_seed, + n_parameters=n_parameters, + ), + convergence_diagnostics=self._restored_bayesian_convergence_diagnostics( + sample_shape=sample_shape, + n_parameters=n_parameters, + ), + sampler_completed=bool(self.fit_result.sampler_completed.value), + best_log_posterior=self.fit_result.best_log_posterior.value, + ) + restored_results.message = self.fit_result.message.value or '' + restored_results.iterations = _int_or_none(self.fit_result.iterations.value) or 0 + return restored_results + def _restore_fit_results_from_projection(self) -> object | None: """Rebuild a runtime fit-result object from saved state.""" if not self._has_persisted_fit_state(): @@ -717,55 +843,11 @@ def _restore_fit_results_from_projection(self) -> object | None: reduced_chi_square = self.fit_result.reduced_chi_square.value if self.fit_result.result_kind.value == FitResultKindEnum.BAYESIAN.value: - posterior_samples = self._restored_posterior_samples() - sample_shape = ( - np.asarray(posterior_samples.parameter_samples).shape - if posterior_samples is not None - else (0, 0, 0) - ) - sampler_settings = self.minimizer._native_kwargs() - sampler_name = ( - 'dream' - if self.minimizer.type == MinimizerTypeEnum.BUMPS_DREAM.value - else str(self.minimizer.type) - ) - restored_results = BayesianFitResults( - success=bool(self.fit_result.success.value), - parameters=restored_parameters, - reduced_chi_square=reduced_chi_square, - starting_parameters=list(restored_parameters), + restored_results = self._restore_bayesian_fit_results_from_projection( + restored_parameters=restored_parameters, fitting_time=fitting_time, - sampler_name=sampler_name, - point_estimate_name=self.fit_result.point_estimate_name.value or 'best_sample', - posterior_samples=posterior_samples, - posterior_parameter_summaries=self._restored_posterior_summaries(), - posterior_predictive=self._restored_predictive_summaries(), - credible_interval_levels=( - float(self.fit_result.credible_interval_inner.value), - float(self.fit_result.credible_interval_outer.value), - ), - sampler_settings={ - 'steps': int(sampler_settings.get('steps', 0)), - 'burn': int(sampler_settings.get('burn', 0)), - 'thin': int(sampler_settings.get('thin', 0)), - 'pop': int(sampler_settings.get('pop', 0)), - 'parallel': int(sampler_settings.get('parallel', 0)), - 'init': str(sampler_settings.get('init', '')), - 'random_seed': sampler_settings.get('random_seed'), - }, - convergence_diagnostics={ - 'converged': False, - 'max_r_hat': self.fit_result.gelman_rubin_max.value, - 'min_ess_bulk': self.fit_result.effective_sample_size_min.value, - 'n_draws': int(sample_shape[0]), - 'n_chains': int(sample_shape[1]), - 'n_parameters': int(sample_shape[2]), - }, - sampler_completed=bool(self.fit_result.sampler_completed.value), - best_log_posterior=self.fit_result.best_log_posterior.value, + reduced_chi_square=reduced_chi_square, ) - restored_results.message = self.fit_result.message.value or '' - restored_results.iterations = _int_or_none(self.fit_result.iterations.value) or 0 self.fit_results = restored_results return restored_results @@ -796,6 +878,79 @@ def _restore_fit_results_from_projection(self) -> object | None: self.fit_results = restored_results return restored_results + def _restored_bayesian_sampler_settings( + self, + sampler_settings: dict[str, object], + *, + random_seed: object | None = None, + n_parameters: int = 0, + ) -> dict[str, object]: + """Return display settings for restored Bayesian results.""" + if self.minimizer.type == MinimizerTypeEnum.EMCEE.value: + restored_settings = { + 'nsteps': self._int_sampler_setting(sampler_settings, 'nsteps'), + 'nburn': self._int_sampler_setting(sampler_settings, 'nburn'), + 'thin': self._int_sampler_setting(sampler_settings, 'thin'), + 'nwalkers': self._int_sampler_setting(sampler_settings, 'nwalkers'), + 'parallel_workers': self._int_sampler_setting( + sampler_settings, + 'parallel_workers', + ), + 'initialization_method': str(sampler_settings.get('initialization_method', '')), + 'proposal_moves': str(sampler_settings.get('proposal_moves', '')), + 'random_seed': random_seed, + } + restored_settings['samples'] = self._sampler_sample_count( + restored_settings, + n_parameters=n_parameters, + ) + return restored_settings + + restored_settings = { + 'steps': self._int_sampler_setting(sampler_settings, 'steps'), + 'burn': self._int_sampler_setting(sampler_settings, 'burn'), + 'thin': self._int_sampler_setting(sampler_settings, 'thin'), + 'pop': self._int_sampler_setting(sampler_settings, 'pop'), + 'parallel': self._int_sampler_setting(sampler_settings, 'parallel'), + 'init': str(sampler_settings.get('init', '')), + 'random_seed': random_seed, + } + restored_settings['samples'] = self._sampler_sample_count( + restored_settings, + n_parameters=n_parameters, + ) + return restored_settings + + def _restored_bayesian_random_seed( + self, + sampler_settings: dict[str, object], + ) -> object | None: + """Return persisted runtime or configured sampler seed.""" + resolved_seed = self.fit_result.resolved_random_seed.value + if resolved_seed is not None: + return int(resolved_seed) + return sampler_settings.get('random_seed') + + @staticmethod + def _int_sampler_setting( + sampler_settings: dict[str, object], + key: str, + ) -> int: + """Return an integer sampler setting with a zero fallback.""" + value = sampler_settings.get(key, 0) + return 0 if value is None else int(value) + + @staticmethod + def _sampler_sample_count( + sampler_settings: dict[str, object], + *, + n_parameters: int, + ) -> int: + """Return restored total sampled scalar count.""" + steps = int(sampler_settings.get('steps') or sampler_settings.get('nsteps') or 0) + population = int(sampler_settings.get('pop') or sampler_settings.get('nwalkers') or 0) + return max(0, steps) * max(0, population) * max(0, int(n_parameters)) + def help(self) -> None: """Print a summary of analysis properties and methods.""" cls = type(self) @@ -926,31 +1081,162 @@ def _get_params_as_dataframe( df.columns = pd.MultiIndex.from_tuples(df.columns) return df - def fit(self) -> None: + def fit( + self, + *, + resume: bool = False, + extra_steps: int | None = None, + ) -> None: """Execute fitting for the currently selected fitting mode.""" mode = FitModeEnum(self._fitting_mode.type) + self._validate_fit_request( + mode=mode, + resume=resume, + extra_steps=extra_steps, + ) + resolved_resume, resolved_extra_steps = self._resolved_resume_request( + resume=resume, + extra_steps=extra_steps, + ) + verb = VerbosityEnum(self.project.verbosity.fit.value) + try: + with notebook_fit_stop_control(verbosity=verb): + self._run_fit_mode( + mode=mode, + resume=resolved_resume, + extra_steps=resolved_extra_steps, + ) + except KeyboardInterrupt: + self._handle_fit_interrupted(verbosity=verb) + + def _run_fit_mode( + self, + *, + mode: FitModeEnum, + resume: bool, + extra_steps: int | None, + ) -> None: + """Dispatch a validated fit request to the selected mode.""" if mode is FitModeEnum.SINGLE: - self._run_single() + self._run_single(resume=resume, extra_steps=extra_steps) elif mode is FitModeEnum.JOINT: self._prepare_joint_fit() - self._run_joint() + self._run_joint(resume=resume, extra_steps=extra_steps) elif mode is FitModeEnum.SEQUENTIAL: self._run_sequential() else: # pragma: no cover msg = f'Unknown fit mode: {mode!r}' raise ValueError(msg) - def _warn_results_sidecar_overwrite(self) -> None: - """Warn before persisted sidecar arrays are overwritten.""" + def _handle_fit_interrupted(self, *, verbosity: VerbosityEnum) -> None: + """Clean up in-memory fit state after a user interrupt.""" + self.fit_results = None + self.fitter.results = None + self._clear_persisted_fit_state() + self._prepare_results_sidecar_for_new_fit() + if verbosity is not VerbosityEnum.SILENT: + console.print('⏹️ Fitting stopped by user.') + + def _resolved_resume_request( + self, + *, + resume: bool, + extra_steps: int | None, + ) -> tuple[bool, int | None]: + """Return executable resume flags for this fit request.""" + if not resume: + return False, extra_steps + + if not self._has_resumable_emcee_sidecar(): + log.warning( + 'resume=True requested, but no saved emcee chain was found; ' + 'starting a fresh fit instead.' + ) + return False, None + + return True, self._resolved_resume_extra_steps(extra_steps) + + def _validate_fit_request( + self, + *, + mode: FitModeEnum, + resume: bool, + extra_steps: int | None, + ) -> None: + """Validate fit options before dispatching to a fitting mode.""" + if extra_steps is not None and not resume: + msg = 'extra_steps is only valid when resume=True.' + raise ValueError(msg) + if resume and mode is not FitModeEnum.SINGLE: + msg = 'Resume is supported in single fit mode only.' + raise ValueError(msg) + + is_emcee = self.minimizer.type == MinimizerTypeEnum.EMCEE.value + if resume and not is_emcee: + msg = "Resume is supported only when analysis.minimizer.type = 'emcee'." + raise ValueError(msg) + if is_emcee and self.project.info.path is None: + msg = ( + 'emcee requires a saved project; call project.save_as() ' + 'before analysis.fit().' + ) + raise ValueError(msg) + if resume and extra_steps is not None: + self._validate_resume_extra_steps(extra_steps) + + @staticmethod + def _validate_resume_extra_steps(extra_steps: object) -> int: + """Validate the emcee resume step count.""" + if extra_steps is None or isinstance(extra_steps, bool): + msg = 'extra_steps must be a positive integer when resume=True.' + raise ValueError(msg) + + try: + integer_steps = int(extra_steps) + except (TypeError, ValueError): + msg = 'extra_steps must be a positive integer when resume=True.' + raise ValueError(msg) from None + if integer_steps != extra_steps or integer_steps < 1: + msg = 'extra_steps must be a positive integer when resume=True.' + raise ValueError(msg) + return integer_steps + + def _resolved_resume_extra_steps(self, extra_steps: int | None) -> int: + """Return explicit or minimizer-default emcee resume steps.""" + if extra_steps is not None: + return self._validate_resume_extra_steps(extra_steps) + return self._validate_resume_extra_steps(self.minimizer.sampling_steps.value) + + def _has_resumable_emcee_sidecar(self) -> bool: + """Return whether the saved project has a resumable chain.""" + project_path = self.project.info.path + if project_path is None: + return False + + sidecar_path = project_path / 'analysis' / 'results.h5' + if not sidecar_path.is_file(): + return False + + try: + with h5py.File(sidecar_path, 'r') as handle: + group = handle.get(EMCEE_CHAIN_GROUP) + if group is None: + return False + return int(group.attrs.get('iteration', 0)) > 0 + except (OSError, TypeError, ValueError): + return False + + def _prepare_results_sidecar_for_new_fit(self) -> None: + """Remove persisted sidecar arrays before a fresh fit.""" project_path = self.project.info.path if project_path is None: return from easydiffraction.io.results_sidecar import ( # noqa: PLC0415 - warn_analysis_results_sidecar_overwrite, + prepare_analysis_results_sidecar_for_new_fit, ) - warn_analysis_results_sidecar_overwrite(analysis_dir=project_path / 'analysis') + prepare_analysis_results_sidecar_for_new_fit(analysis_dir=project_path / 'analysis') def _prepare_joint_fit(self) -> None: """ @@ -1088,8 +1374,8 @@ def _minimizer_swap_diff( on ``new_minimizer`` (a value the user previously customised is no longer applicable). ``added`` lists settings introduced by the new minimizer with their default value. ``changed`` lists - settings shared by both whose default value differs, in the - ``'{name}={old!r}->{new!r}'`` form. + settings shared by both whose default value differs, in a + ``'{name}: {old!r} -> {new!r}'`` form. """ old_values = old_minimizer._descriptor_values(old_minimizer._setting_descriptor_names) new_values = new_minimizer._descriptor_values(new_minimizer._setting_descriptor_names) @@ -1098,7 +1384,7 @@ def _minimizer_swap_diff( removed = sorted(old_keys - new_keys) added = sorted(f'{name}={new_values[name]!r}' for name in (new_keys - old_keys)) changed = sorted( - f'{name}={old_values[name]!r}->{new_values[name]!r}' + f'{name}: {old_values[name]!r} -> {new_values[name]!r}' for name in (old_keys & new_keys) if old_values[name] != new_values[name] ) @@ -1121,21 +1407,33 @@ def _warn_about_minimizer_swap_defaults( """ removed, added, changed = cls._minimizer_swap_diff(old_minimizer, new_minimizer) if removed: - log.warning(f'Switching minimizer type removes these settings: {", ".join(removed)}.') + log.warning( + format_bulleted_warning( + 'Switching minimizer type removes these settings:', + removed, + ) + ) if added: log.warning( - f'Switching minimizer type adds these settings with defaults: {", ".join(added)}.' + format_bulleted_warning( + 'Switching minimizer type adds these settings with defaults:', + added, + ) ) if changed: log.warning( - f'Switching minimizer type changes these default values: {", ".join(changed)}.' + format_bulleted_warning( + 'Switching minimizer type changes these default values:', + changed, + ) ) def _sync_engine_from_minimizer_category(self) -> None: """Apply minimizer category settings to the live engine.""" engine = self.fitter.minimizer + skip_keys = type(self.minimizer)._engine_sync_skip_keys for key, value in self.minimizer._native_kwargs().items(): - if key == 'random_seed': + if key in skip_keys: continue if not hasattr(engine, key): log.warning( @@ -1190,7 +1488,7 @@ def _fit_state_categories(self) -> list[object]: ] try: - result_kind = FitResultKindEnum(self.fit_result.result_kind.value) + FitResultKindEnum(self.fit_result.result_kind.value) except ValueError: log.warning( 'Unsupported fit_result.result_kind while serializing analysis CIF: ' @@ -1199,9 +1497,6 @@ def _fit_state_categories(self) -> list[object]: ) return categories - if result_kind is FitResultKindEnum.DETERMINISTIC: - return categories - return categories def _clear_persisted_fit_state(self) -> None: @@ -1585,7 +1880,7 @@ def _store_posterior_predictive_projection( results.posterior_predictive[summary.experiment_name] = summary results.posterior_predictive[ - self._predictive_cache_key( + posterior_predictive_cache_key( summary.experiment_name, str(x_axis_name), include_draws=False, @@ -1688,6 +1983,7 @@ def _store_posterior_fit_projection(self, results: BayesianFitResults) -> None: self.fit_result._set_best_log_posterior(results.best_log_posterior) self.fit_result._set_credible_interval_inner(credible_interval_inner) self.fit_result._set_credible_interval_outer(credible_interval_outer) + self.fit_result._set_resolved_random_seed(self._bayesian_result_random_seed(results)) self.fit_result._set_gelman_rubin_max(convergence.get('max_r_hat')) self.fit_result._set_effective_sample_size_min(convergence.get('min_ess_bulk')) self.fit_result._set_acceptance_rate_mean(convergence.get('acceptance_rate_mean')) @@ -1720,6 +2016,12 @@ def _store_posterior_fit_projection(self, results: BayesianFitResults) -> None: source_kind=FitCorrelationSourceEnum.POSTERIOR, ) + @staticmethod + def _bayesian_result_random_seed(results: BayesianFitResults) -> int | None: + """Return the runtime seed from Bayesian result settings.""" + seed = results.sampler_settings.get('random_seed') + return None if seed is None else int(seed) + def _store_fit_result_projection( self, results: FitResults, @@ -1766,7 +2068,11 @@ def _resolve_sequential_data_dir(self) -> Path: return project_path / data_dir - def _prepare_fit_run(self) -> tuple[VerbosityEnum, object, object] | None: + def _prepare_fit_run( + self, + *, + resume: bool = False, + ) -> tuple[VerbosityEnum, object, object] | None: """Resolve common inputs for single and joint fitting.""" verb = VerbosityEnum(self.project.verbosity.fit.value) structures = self.project.structures @@ -1779,7 +2085,8 @@ def _prepare_fit_run(self) -> tuple[VerbosityEnum, object, object] | None: log.warning('No experiments found in the project. Cannot run fit.') return None - self._warn_results_sidecar_overwrite() + if not resume: + self._prepare_results_sidecar_for_new_fit() # Apply constraints before fitting so that user-constrained # parameters are marked and excluded from the free parameter @@ -1789,11 +2096,16 @@ def _prepare_fit_run(self) -> tuple[VerbosityEnum, object, object] | None: return verb, structures, experiments - def _run_single(self) -> None: + def _run_single( + self, + *, + resume: bool = False, + extra_steps: int | None = None, + ) -> None: """ Execute single-mode fitting with current project verbosity. """ - prepared = self._prepare_fit_run() + prepared = self._prepare_fit_run(resume=resume) if prepared is None: return @@ -1802,16 +2114,24 @@ def _run_single(self) -> None: verb, structures, experiments, - use_physical_limits=False, - random_seed=None, + fit_options=FitterFitOptions(resume=resume, extra_steps=extra_steps), ) if self.project.info.path is not None: self.project.save() - def _run_joint(self) -> None: + def _run_joint( + self, + *, + resume: bool = False, + extra_steps: int | None = None, + ) -> None: """Execute joint-mode fitting with current project verbosity.""" - prepared = self._prepare_fit_run() + if resume: + msg = 'Resume is supported in single fit mode only.' + raise ValueError(msg) + + prepared = self._prepare_fit_run(resume=resume) if prepared is None: return @@ -1820,8 +2140,7 @@ def _run_joint(self) -> None: verb, structures, experiments, - use_physical_limits=False, - random_seed=None, + fit_options=FitterFitOptions(resume=resume, extra_steps=extra_steps), ) if self.project.info.path is not None: @@ -1835,7 +2154,7 @@ def _run_sequential(self) -> None: self._set_fitting_mode_type(FitModeEnum.SEQUENTIAL.value) self._update_categories() - self._warn_results_sidecar_overwrite() + self._prepare_results_sidecar_for_new_fit() self._clear_persisted_fit_state() max_workers_value = self._sequential_fit.max_workers.value @@ -1870,8 +2189,7 @@ def _fit_joint( structures: object, experiments: object, *, - use_physical_limits: bool, - random_seed: int | None, + fit_options: FitterFitOptions, ) -> None: """ Run joint fitting across all experiments with weights. @@ -1884,11 +2202,18 @@ def _fit_joint( Project structures collection. experiments : object Project experiments collection. - use_physical_limits : bool - Whether to use physical limits as fit bounds. - random_seed : int | None - Optional random seed passed to stochastic minimizers. + fit_options : FitterFitOptions + Execution options controlling limits, randomness and resume. + + Raises + ------ + ValueError + If resume is requested for joint fitting. """ + if fit_options.resume: + msg = 'Resume is supported in single fit mode only.' + raise ValueError(msg) + mode = FitModeEnum.JOINT # Auto-populate joint_fit if empty if not len(self._joint_fit): @@ -1908,8 +2233,12 @@ def _fit_joint( weights=weights_array, analysis=self, verbosity=verb, - use_physical_limits=use_physical_limits, - random_seed=self._resolved_fit_random_seed(random_seed), + options=FitterFitOptions( + use_physical_limits=fit_options.use_physical_limits, + random_seed=self._resolved_fit_random_seed(fit_options.random_seed), + resume=fit_options.resume, + extra_steps=fit_options.extra_steps, + ), ) # After fitting, get the results @@ -1921,8 +2250,7 @@ def _fit_single( structures: object, experiments: object, *, - use_physical_limits: bool, - random_seed: int | None, + fit_options: FitterFitOptions, ) -> None: """ Run single-mode fitting for each experiment independently. @@ -1935,13 +2263,20 @@ def _fit_single( Project structures collection. experiments : object Project experiments collection. - use_physical_limits : bool - Whether to use physical limits as fit bounds. - random_seed : int | None - Optional random seed passed to stochastic minimizers. + fit_options : FitterFitOptions + Execution options controlling limits, randomness and resume. + + Raises + ------ + ValueError + If resume is requested for more than one single-fit + experiment. """ mode = FitModeEnum.SINGLE expt_names = experiments.names + if fit_options.resume and len(expt_names) != 1: + msg = 'Resume is supported for one single-fit experiment at a time.' + raise ValueError(msg) short_display_handle = self._fit_single_print_header(verb, expt_names, mode) short_rows: list[list[str]] = [] @@ -1952,8 +2287,7 @@ def _fit_single( verb, structures, experiments, - use_physical_limits=use_physical_limits, - random_seed=random_seed, + fit_options=fit_options, short_state=(short_rows, short_display_handle), ) finally: @@ -1970,8 +2304,7 @@ def _fit_single_experiments( structures: object, experiments: object, *, - use_physical_limits: bool, - random_seed: int | None, + fit_options: FitterFitOptions, short_state: tuple[list[list[str]], object], ) -> None: """Run the per-experiment loop for single-fit mode.""" @@ -1989,8 +2322,12 @@ def _fit_single_experiments( [experiment], analysis=self, verbosity=verb, - use_physical_limits=use_physical_limits, - random_seed=self._resolved_fit_random_seed(random_seed), + options=FitterFitOptions( + use_physical_limits=fit_options.use_physical_limits, + random_seed=self._resolved_fit_random_seed(fit_options.random_seed), + resume=fit_options.resume, + extra_steps=fit_options.extra_steps, + ), ) results = self.fitter.results diff --git a/src/easydiffraction/analysis/categories/fit_parameters/default.py b/src/easydiffraction/analysis/categories/fit_parameters/default.py index 2eed6ee1f..9db1ae05a 100644 --- a/src/easydiffraction/analysis/categories/fit_parameters/default.py +++ b/src/easydiffraction/analysis/categories/fit_parameters/default.py @@ -4,9 +4,12 @@ from __future__ import annotations +from typing import ClassVar + import numpy as np from easydiffraction.analysis.categories.fit_parameters.factory import FitParametersFactory +from easydiffraction.analysis.enums import FitResultKindEnum from easydiffraction.core.category import CategoryCollection from easydiffraction.core.category import CategoryItem from easydiffraction.core.metadata import TypeInfo @@ -23,6 +26,27 @@ class FitParameterItem(CategoryItem): _category_code = 'fit_parameter' _category_entry_name = 'param_unique_name' + _control_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'param_unique_name', + 'fit_min', + 'fit_max', + 'start_value', + 'start_uncertainty', + ) + _optional_control_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'fit_bounds_uncertainty_multiplier', + ) + _posterior_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'posterior_best_sample_value', + 'posterior_median', + 'posterior_uncertainty', + 'posterior_interval_68_low', + 'posterior_interval_68_high', + 'posterior_interval_95_low', + 'posterior_interval_95_high', + 'posterior_gelman_rubin', + 'posterior_effective_sample_size_bulk', + ) def __init__(self) -> None: super().__init__() @@ -332,6 +356,35 @@ class FitParameters(CategoryCollection): def __init__(self) -> None: super().__init__(item_type=FitParameterItem) + def _include_posterior_cif_descriptors(self) -> bool: + """Return whether CIF output includes posterior columns.""" + parent = getattr(self, '_parent', None) + fit_result = getattr(parent, 'fit_result', None) + result_kind = getattr(getattr(fit_result, 'result_kind', None), 'value', None) + if result_kind is not None: + return result_kind == FitResultKindEnum.BAYESIAN.value + return any(item.has_posterior_summary() for item in self) + + def _include_uncertainty_multiplier_cif_descriptor(self) -> bool: + """Return whether CIF output includes the bounds multiplier.""" + return any(item.fit_bounds_uncertainty_multiplier.value is not None for item in self) + + def _cif_loop_parameters(self, item: FitParameterItem) -> list[object]: + """Return CIF loop descriptors for the current fit kind.""" + descriptor_names = FitParameterItem._control_descriptor_names + if self._include_uncertainty_multiplier_cif_descriptor(): + descriptor_names = ( + *descriptor_names[:3], + *FitParameterItem._optional_control_descriptor_names, + *descriptor_names[3:], + ) + if self._include_posterior_cif_descriptors(): + descriptor_names = ( + *descriptor_names, + *FitParameterItem._posterior_descriptor_names, + ) + return [getattr(item, name) for name in descriptor_names] + def create( self, *, diff --git a/src/easydiffraction/analysis/categories/fit_result/bayesian.py b/src/easydiffraction/analysis/categories/fit_result/bayesian.py index 91e1310f1..2cd2c2c58 100644 --- a/src/easydiffraction/analysis/categories/fit_result/bayesian.py +++ b/src/easydiffraction/analysis/categories/fit_result/bayesian.py @@ -11,6 +11,7 @@ from easydiffraction.core.metadata import TypeInfo from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import IntegerDescriptor from easydiffraction.core.variable import NumericDescriptor from easydiffraction.core.variable import StringDescriptor from easydiffraction.io.cif.handler import CifHandler @@ -30,11 +31,17 @@ class BayesianFitResult(FitResultBase): 'sampler_completed', 'credible_interval_inner', 'credible_interval_outer', + 'resolved_random_seed', 'acceptance_rate_mean', 'gelman_rubin_max', 'effective_sample_size_min', 'best_log_posterior', ) + _optional_result_descriptor_names: ClassVar[tuple[str, ...]] = ( + 'acceptance_rate_mean', + 'resolved_random_seed', + ) + _omitted_result_descriptor_names: ClassVar[tuple[str, ...]] = ('iterations',) _expected_descriptor_names: ClassVar[tuple[str, ...]] = _result_descriptor_names def __init__(self) -> None: @@ -43,6 +50,7 @@ def __init__(self) -> None: self._sampler_completed = self._sampler_completed_descriptor() self._credible_interval_inner = self._credible_interval_inner_descriptor() self._credible_interval_outer = self._credible_interval_outer_descriptor() + self._resolved_random_seed = self._resolved_random_seed_descriptor() self._acceptance_rate_mean = self._acceptance_rate_mean_descriptor() self._gelman_rubin_max = self._gelman_rubin_max_descriptor() self._effective_sample_size_min = self._effective_sample_size_min_descriptor() @@ -98,6 +106,16 @@ def _acceptance_rate_mean_descriptor() -> NumericDescriptor: cif_handler=CifHandler(names=['_fit_result.acceptance_rate_mean']), ) + @staticmethod + def _resolved_random_seed_descriptor() -> IntegerDescriptor: + """Create a resolved-random-seed descriptor.""" + return IntegerDescriptor( + name='resolved_random_seed', + description='Runtime random seed used by the sampler.', + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_fit_result.resolved_random_seed']), + ) + @staticmethod def _gelman_rubin_max_descriptor() -> NumericDescriptor: """Create a Gelman-Rubin descriptor.""" @@ -168,6 +186,15 @@ def _set_credible_interval_outer(self, value: float) -> None: """ self._credible_interval_outer.value = value + @property + def resolved_random_seed(self) -> IntegerDescriptor: + """Runtime random seed used by the sampler.""" + return self._resolved_random_seed + + def _set_resolved_random_seed(self, value: int | None) -> None: + """Set the resolved random seed for internal callers.""" + self._resolved_random_seed.value = value + @property def acceptance_rate_mean(self) -> NumericDescriptor: """Mean sampler acceptance rate.""" @@ -205,3 +232,19 @@ def best_log_posterior(self) -> NumericDescriptor: def _set_best_log_posterior(self, value: float | None) -> None: """Set the best log-posterior for internal callers.""" self._best_log_posterior.value = value + + def _cif_parameters(self) -> list[object]: + """Return Bayesian fit-result descriptors for CIF output.""" + omitted_descriptor_ids = { + id(getattr(self, name)) for name in self._omitted_result_descriptor_names + } + optional_descriptor_ids = { + id(getattr(self, name)) + for name in self._optional_result_descriptor_names + if getattr(self, name).value is None + } + return [ + descriptor + for descriptor in self.parameters + if id(descriptor) not in omitted_descriptor_ids | optional_descriptor_ids + ] diff --git a/src/easydiffraction/analysis/categories/fit_result/lsq.py b/src/easydiffraction/analysis/categories/fit_result/lsq.py index 9e8e31ec9..e997ffa66 100644 --- a/src/easydiffraction/analysis/categories/fit_result/lsq.py +++ b/src/easydiffraction/analysis/categories/fit_result/lsq.py @@ -211,6 +211,21 @@ def correlation_available(self) -> BoolDescriptor: def _set_correlation_available(self, *, value: bool | None) -> None: self._correlation_available.value = value + def _include_exit_reason_cif_descriptor(self) -> bool: + """Return whether exit_reason adds distinct information.""" + exit_reason = self.exit_reason.value + if exit_reason is None: + return False + return exit_reason != self.message.value + + def _cif_parameters(self) -> list[object]: + """Return LSQ fit-result descriptors active for CIF output.""" + return [ + descriptor + for descriptor in self.parameters + if descriptor is not self.exit_reason or self._include_exit_reason_cif_descriptor() + ] + @property def exit_reason(self) -> StringDescriptor: """Backend exit reason for the persisted deterministic fit.""" diff --git a/src/easydiffraction/analysis/categories/minimizer/__init__.py b/src/easydiffraction/analysis/categories/minimizer/__init__.py index 17ab65c81..933b35a69 100644 --- a/src/easydiffraction/analysis/categories/minimizer/__init__.py +++ b/src/easydiffraction/analysis/categories/minimizer/__init__.py @@ -9,6 +9,7 @@ from easydiffraction.analysis.categories.minimizer.bumps_dream import BumpsDreamMinimizer from easydiffraction.analysis.categories.minimizer.bumps_lm import BumpsLmMinimizer from easydiffraction.analysis.categories.minimizer.dfols import DfolsMinimizer +from easydiffraction.analysis.categories.minimizer.emcee import EmceeMinimizer from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory from easydiffraction.analysis.categories.minimizer.lmfit import LmfitMinimizer from easydiffraction.analysis.categories.minimizer.lmfit_least_squares import ( diff --git a/src/easydiffraction/analysis/categories/minimizer/base.py b/src/easydiffraction/analysis/categories/minimizer/base.py index fb583964d..8d0130bcd 100644 --- a/src/easydiffraction/analysis/categories/minimizer/base.py +++ b/src/easydiffraction/analysis/categories/minimizer/base.py @@ -24,6 +24,7 @@ class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): _owner_attr_name = 'minimizer' _swap_method_name = '_swap_minimizer' _native_key_map: ClassVar[dict[str, str]] = {} + _engine_sync_skip_keys: ClassVar[frozenset[str]] = frozenset({'random_seed'}) _setting_descriptor_names: ClassVar[tuple[str, ...]] = () _result_descriptor_names: ClassVar[tuple[str, ...]] = () _fit_result_class: ClassVar[type[FitResultBase]] = FitResultBase diff --git a/src/easydiffraction/analysis/categories/minimizer/emcee.py b/src/easydiffraction/analysis/categories/minimizer/emcee.py new file mode 100644 index 000000000..6fc13eee5 --- /dev/null +++ b/src/easydiffraction/analysis/categories/minimizer/emcee.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Persisted category for the emcee minimizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.analysis.categories.minimizer.bayesian_base import BayesianMinimizerBase +from easydiffraction.analysis.categories.minimizer.factory import MinimizerCategoryFactory +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_INITIALIZATION_METHOD +from easydiffraction.analysis.minimizers.emcee_defaults import ( + DEFAULT_NBURN as DEFAULT_BURN_IN_STEPS, +) +from easydiffraction.analysis.minimizers.emcee_defaults import ( + DEFAULT_NSTEPS as DEFAULT_SAMPLING_STEPS, +) +from easydiffraction.analysis.minimizers.emcee_defaults import ( + DEFAULT_NWALKERS as DEFAULT_POPULATION_SIZE, +) +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_PARALLEL_WORKERS +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_PROPOSAL_MOVES +from easydiffraction.analysis.minimizers.emcee_defaults import ( + DEFAULT_THIN as DEFAULT_THINNING_INTERVAL, +) +from easydiffraction.analysis.minimizers.emcee_defaults import SUPPORTED_PROPOSAL_MOVES +from easydiffraction.analysis.minimizers.enums import InitializationMethodEnum +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.io.cif.handler import CifHandler + + +@MinimizerCategoryFactory.register +class EmceeMinimizer(BayesianMinimizerBase): + """Persisted settings for the emcee minimizer.""" + + _engine_metadata: ClassVar[dict[str, str]] = { + 'optimizer_name': 'emcee', + 'method_name': 'de', + } + _expected_descriptor_names: ClassVar[tuple[str, ...]] = ( + *BayesianMinimizerBase._expected_descriptor_names, + 'proposal_moves', + ) + _native_key_map: ClassVar[dict[str, str]] = { + 'sampling_steps': 'nsteps', + 'burn_in_steps': 'nburn', + 'thinning_interval': 'thin', + 'population_size': 'nwalkers', + 'parallel_workers': 'parallel_workers', + 'initialization_method': 'initialization_method', + 'random_seed': 'random_seed', + 'proposal_moves': 'proposal_moves', + } + _engine_sync_skip_keys: ClassVar[frozenset[str]] = frozenset({ + 'random_seed', + 'parallel_workers', + }) + _setting_descriptor_names: ClassVar[tuple[str, ...]] = ( + *BayesianMinimizerBase._setting_descriptor_names, + 'proposal_moves', + ) + _supported_initialization_methods: ClassVar[tuple[InitializationMethodEnum, ...]] = ( + InitializationMethodEnum.BALL, + InitializationMethodEnum.UNIFORM, + InitializationMethodEnum.PRIOR, + ) + type_info = TypeInfo( + tag=MinimizerTypeEnum.EMCEE, + description='emcee affine-invariant ensemble Bayesian sampling', + ) + + def __init__(self) -> None: + super().__init__() + self._sampling_steps = self._sampling_steps_descriptor(DEFAULT_SAMPLING_STEPS) + self._burn_in_steps = self._burn_in_steps_descriptor(DEFAULT_BURN_IN_STEPS) + self._thinning_interval = self._thinning_interval_descriptor(DEFAULT_THINNING_INTERVAL) + self._population_size = self._population_size_descriptor(DEFAULT_POPULATION_SIZE) + self._parallel_workers = self._parallel_workers_descriptor(DEFAULT_PARALLEL_WORKERS) + self._initialization_method = self._initialization_method_descriptor() + self._random_seed = self._random_seed_descriptor() + self._proposal_moves = self._proposal_moves_descriptor() + + @classmethod + def _initialization_method_descriptor(cls) -> StringDescriptor: + """Create an emcee initialization-method descriptor.""" + allowed = [member.value for member in cls._supported_initialization_methods] + return StringDescriptor( + name='initialization_method', + description='emcee walker initialization method.', + value_spec=AttributeSpec( + default=DEFAULT_INITIALIZATION_METHOD.value, + validator=MembershipValidator(allowed=allowed), + ), + cif_handler=CifHandler(names=['_minimizer.initialization_method']), + ) + + @staticmethod + def _proposal_moves_descriptor() -> StringDescriptor: + """Create an emcee proposal-moves descriptor.""" + return StringDescriptor( + name='proposal_moves', + description='Single emcee proposal move; move mixtures are not persisted in v1.', + value_spec=AttributeSpec( + default=DEFAULT_PROPOSAL_MOVES, + validator=MembershipValidator(allowed=SUPPORTED_PROPOSAL_MOVES), + ), + cif_handler=CifHandler(names=['_minimizer.proposal_moves']), + ) + + @property + def proposal_moves(self) -> StringDescriptor: + """Single emcee proposal move.""" + return self._proposal_moves + + @proposal_moves.setter + def proposal_moves(self, value: str) -> None: + self._proposal_moves.value = value diff --git a/src/easydiffraction/analysis/fit_helpers/_diagnostics.py b/src/easydiffraction/analysis/fit_helpers/_diagnostics.py new file mode 100644 index 000000000..a50fb5c5d --- /dev/null +++ b/src/easydiffraction/analysis/fit_helpers/_diagnostics.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +MCMC convergence diagnostics computed in pure NumPy + SciPy. + +The two diagnostics this module produces - split-chain Gelman-Rubin +R-hat and bulk effective sample size (ESS) - are the only Bayesian +diagnostics EasyDiffraction reports. The implementations follow the +standard formulas described in Vehtari, Gelman, Simpson, Carpenter and +Buerkner (2019), *Rank-normalization, folding, and localization: An +improved R-hat for assessing convergence of MCMC* +(https://arxiv.org/abs/1903.08008), Stan's reference manual, and Geyer +(1992), *Practical Markov chain Monte Carlo*. + +Inputs use the project's preserved layout: a 2-D NumPy array of shape +``(n_draws, n_chains)`` per parameter, never an ArviZ ``InferenceData`` +object. +""" + +from __future__ import annotations + +import numpy as np +from scipy import stats + +_MIN_DRAWS = 4 +_SAMPLE_NDIM = 2 +_MIN_RHAT_CHAINS = 2 +_MIN_ESS_CHAINS = 1 + + +def compute_r_hat(samples: np.ndarray) -> float: + """ + Split-chain Gelman-Rubin R-hat for one parameter. + + Each chain is split in half (the standard "split R-hat" variant); + the within-chain (W) and between-chain (B) variances are computed on + the doubled chain set, and R-hat is returned as ``sqrt(Vhat / W)`` + where ``Vhat = ((n-1)/n) * W + B/n``. + + Parameters + ---------- + samples : np.ndarray + Posterior samples for one parameter with shape ``(n_draws, + n_chains)``. + + Returns + ------- + float + R-hat value. ``nan`` when there are too few draws or chains, or + zero within-chain variance. + + Raises + ------ + ValueError + If ``samples`` is not 2-D. + """ + if samples.ndim != _SAMPLE_NDIM: + msg = 'samples must have shape (n_draws, n_chains)' + raise ValueError(msg) + n_draws, n_chains = samples.shape + if n_draws < _MIN_DRAWS or n_chains < _MIN_RHAT_CHAINS: + return float('nan') + + # Split each chain in half. For odd draws, drop the middle sample. + half = n_draws // 2 + splits = np.concatenate( + [samples[:half, :], samples[-half:, :]], + axis=1, + ) + + n_split = splits.shape[0] + chain_means = splits.mean(axis=0) + chain_vars = splits.var(axis=0, ddof=1) + + within = float(chain_vars.mean()) + if within == 0 or not np.isfinite(within): + return float('nan') + + between = n_split * float(chain_means.var(ddof=1)) + var_hat = ((n_split - 1) / n_split) * within + between / n_split + return float(np.sqrt(var_hat / within)) + + +def compute_ess_bulk(samples: np.ndarray) -> float: + """ + Bulk effective sample size for one parameter. + + Samples are rank-normalized across all chain/draw pairs (so the + diagnostic is robust to heavy-tailed marginals); the autocorrelation + function is then averaged across chains and summed with Geyer's + initial positive sequence: pairs of consecutive lags are added to + the running variance estimate until a pair first becomes + non-positive (Geyer 1992; Vehtari et al. 2019 section 3.1). + + Parameters + ---------- + samples : np.ndarray + Posterior samples for one parameter with shape ``(n_draws, + n_chains)``. + + Returns + ------- + float + Effective sample size in the bulk of the posterior. ``nan`` when + there are fewer than 4 draws, no chains, zero variance, or the + autocorrelation sum is non-positive. + + Raises + ------ + ValueError + If ``samples`` is not 2-D. + """ + if samples.ndim != _SAMPLE_NDIM: + msg = 'samples must have shape (n_draws, n_chains)' + raise ValueError(msg) + n_draws, n_chains = samples.shape + if n_draws < _MIN_DRAWS or n_chains < _MIN_ESS_CHAINS: + return float('nan') + + total = n_draws * n_chains + + # Rank-normalize across all samples into standard normal scores. + ranks = stats.rankdata(samples.ravel()).reshape(samples.shape) + z = stats.norm.ppf((ranks - 0.5) / total) + + # Per-chain autocorrelation, then average across chains. + acf_sum = np.zeros(n_draws) + for chain_index in range(n_chains): + acf_sum += _autocorr_fft(z[:, chain_index]) + rho = acf_sum / n_chains + + if not np.isfinite(rho[0]) or rho[0] <= 0: + return float('nan') + + # Geyer's initial positive sequence on pairs of lags. + tau = 1.0 + for t in range(1, n_draws // 2): + pair = rho[2 * t - 1] + rho[2 * t] + if pair <= 0: + break + tau += 2.0 * pair + + if tau <= 0 or not np.isfinite(tau): + return float('nan') + return float(total / tau) + + +def _autocorr_fft(series: np.ndarray) -> np.ndarray: + """ + Return the normalized autocorrelation function via FFT. + + Pads to the next power of two so the FFT is well-conditioned for + arbitrary chain lengths. The returned ACF has the same length as the + input series and starts at ``rho[0] = 1`` when the input has + non-zero variance. + + Parameters + ---------- + series : np.ndarray + One-dimensional sequence of samples. + + Returns + ------- + np.ndarray + Autocorrelation values at lags 0, 1, …, ``len(series) - 1``. + """ + n = series.size + centered = series - series.mean() + size = 1 << (2 * n - 1).bit_length() + fx = np.fft.fft(centered, n=size) + acf = np.fft.ifft(fx * np.conj(fx))[:n].real + if acf[0] == 0: + return acf + return acf / acf[0] diff --git a/src/easydiffraction/analysis/fit_helpers/bayesian.py b/src/easydiffraction/analysis/fit_helpers/bayesian.py index 20dd78a36..bc50ba7a7 100644 --- a/src/easydiffraction/analysis/fit_helpers/bayesian.py +++ b/src/easydiffraction/analysis/fit_helpers/bayesian.py @@ -6,20 +6,21 @@ from dataclasses import dataclass -import arviz as az import numpy as np -from rich.text import Text +from easydiffraction.analysis.fit_helpers._diagnostics import compute_ess_bulk +from easydiffraction.analysis.fit_helpers._diagnostics import compute_r_hat from easydiffraction.analysis.fit_helpers.metrics import calculate_r_factor from easydiffraction.analysis.fit_helpers.metrics import calculate_r_factor_squared from easydiffraction.analysis.fit_helpers.metrics import calculate_rb_factor from easydiffraction.analysis.fit_helpers.metrics import calculate_weighted_r_factor from easydiffraction.analysis.fit_helpers.reporting import FitResults from easydiffraction.analysis.fit_helpers.reporting import _build_parameter_row -from easydiffraction.analysis.fit_helpers.reporting import _format_optional_float +from easydiffraction.analysis.fit_helpers.reporting import _overall_status_row_label from easydiffraction.core.posterior import PosteriorParameterSummary from easydiffraction.utils.logging import console -from easydiffraction.utils.logging import log +from easydiffraction.utils.utils import print_metrics_table +from easydiffraction.utils.utils import print_table_footnote from easydiffraction.utils.utils import render_table R_HAT_CONVERGENCE_THRESHOLD = 1.01 @@ -32,6 +33,17 @@ DiagnosticsMap = dict[str, object] | None +def posterior_predictive_cache_key( + experiment_name: str, + x_axis_name: str, + *, + include_draws: bool = True, +) -> str: + """Return the cache key for one posterior predictive summary.""" + key_suffix = 'draws' if include_draws else 'band' + return f'{experiment_name}:{x_axis_name}:{key_suffix}' + + @dataclass(slots=True) class PosteriorPredictiveSummary: """ @@ -105,20 +117,21 @@ def flattened(self) -> np.ndarray: """ return np.asarray(self.parameter_samples).reshape(-1, len(self.parameter_names)) - def to_arviz(self) -> object: + def validate_shapes(self) -> tuple[int, int, int]: """ - Convert posterior samples to an ArviZ ``InferenceData`` object. + Validate stored sample shapes. Returns ------- - object - ArviZ ``InferenceData`` instance built from the stored - posterior samples. + tuple[int, int, int] + Tuple ``(n_draws, n_chains, n_parameters)``. Raises ------ ValueError - If the stored arrays do not have the expected shapes. + If the sample array is not 3-D, the parameter axis does not + match ``parameter_names``, or ``log_posterior`` (when + present) does not match the first two sample axes. """ posterior_array = np.asarray(self.parameter_samples, dtype=float) if posterior_array.ndim != POSTERIOR_SAMPLE_NDIM: @@ -130,24 +143,13 @@ def to_arviz(self) -> object: msg = 'Posterior sample array does not match the parameter name list length.' raise ValueError(msg) - posterior_dict = { - name: np.transpose(posterior_array[:, :, index], (1, 0)) - for index, name in enumerate(self.parameter_names) - } - - sample_stats: dict[str, np.ndarray] | None = None if self.log_posterior is not None: log_posterior = np.asarray(self.log_posterior, dtype=float) if log_posterior.shape != (n_draws, n_chains): msg = 'Log-posterior array must match the first two posterior sample axes.' raise ValueError(msg) - sample_stats = {'lp': np.transpose(log_posterior, (1, 0))} - data = {'posterior': posterior_dict} - if sample_stats is not None: - data['sample_stats'] = sample_stats - - return az.from_dict(data) + return n_draws, n_chains, n_parameters SummaryList = list[PosteriorParameterSummary] | None @@ -284,60 +286,131 @@ def display_results( f_calc=f_calc, ) - self._display_summary_header() - _print_fit_quality_metrics(metrics) + console.print('📋 Bayesian fit results:') + print_metrics_table(self._build_fit_results_rows(metrics)) console.print('📈 Committed parameters:') _render_committed_parameter_table(self.parameters) + print_table_footnote(_COMMITTED_PARAMETERS_FOOTNOTE) - console.print('📊 Posterior parameter summaries:') + console.print('📊 Posterior distribution:') _render_posterior_summary_table( parameters=self.parameters, posterior_parameter_summaries=self.posterior_parameter_summaries, ) + print_table_footnote(_POSTERIOR_DISTRIBUTION_FOOTNOTE) self._print_table_notes() + def _build_fit_results_rows(self, metrics: dict[str, float | None]) -> list[list[str]]: + """Return the rows for the 'Bayesian fit results' table.""" + overall_status = _bayesian_overall_status( + success=self.success, + sampler_completed=self.sampler_completed, + convergence_diagnostics=self.convergence_diagnostics, + ) + + rows: list[list[str]] = [] + _append_bayesian_identity_rows(results=self, rows=rows, overall_status=overall_status) + _append_fit_quality_rows(results=self, rows=rows, metrics=metrics) + _append_convergence_rows(rows=rows, diagnostics=self.convergence_diagnostics or {}) + return rows + def _print_table_notes(self) -> None: """ Print parameter and posterior-diagnostic notes below tables. """ super()._print_table_notes() - for note in _posterior_table_notes(self.posterior_parameter_summaries): - log.warning(note) + notes = _posterior_table_notes(self.posterior_parameter_summaries) + if notes: + console.small(*notes) - def _display_summary_header(self) -> None: - """Render the high-level Bayesian fit summary.""" - status_icon, overall_status = _format_bayesian_overall_status( - success=self.success, - sampler_completed=self.sampler_completed, - convergence_diagnostics=self.convergence_diagnostics, - ) - fitting_time = _format_optional_float(self.fitting_time, suffix=' seconds') - goodness_of_fit = _format_optional_float(self.reduced_chi_square) - - console.paragraph('Bayesian fit results') - console.print(f'{status_icon} Overall status: {overall_status}') - if self.message: - console.print(f'💬 Sampler status: {self.message}') - console.print(f'🧪 Sampler: {self.sampler_name}') - console.print( - f'🎯 Committed point estimate: {_format_point_estimate_name(self.point_estimate_name)}' - ) - sampler_completed = 'yes' if self.sampler_completed else 'no' - console.print(f'🔁 Sampler completed: {sampler_completed}') - console.print(f'⏱️ Fitting time: {fitting_time}') - console.print(f'📏 Goodness-of-fit (reduced χ²): {goodness_of_fit}') - if self.best_log_posterior is not None: - console.print(f'📉 Best log-posterior: {self.best_log_posterior:.2f}') - sampler_settings = _format_sampler_settings(self.sampler_settings) - if sampler_settings is not None: - console.print(Text(f'⚙️ Sampler settings: {sampler_settings}')) +def _append_bayesian_identity_rows( + *, + results: BayesianFitResults, + rows: list[list[str]], + overall_status: str, +) -> None: + """Append sampler identity and status rows.""" + sampler_label = results.minimizer_type or results.sampler_name + if sampler_label: + rows.append(['🧪 Sampler', str(sampler_label)]) + rows.append([_overall_status_row_label(overall_status), overall_status]) + if results.message: + rows.append(['💬 Engine message', results.message]) + - convergence_summary = _format_convergence_summary(self.convergence_diagnostics) - if convergence_summary is not None: - console.print(Text.from_markup(f'📊 Convergence: {convergence_summary}')) +def _append_fit_quality_rows( + *, + results: BayesianFitResults, + rows: list[list[str]], + metrics: dict[str, float | None], +) -> None: + """Append fit-quality and best-posterior rows.""" + if results.fitting_time is not None: + rows.append(['⏱️ Fitting time (seconds)', f'{results.fitting_time:.2f}']) + if results.reduced_chi_square is not None: + rows.append([ + '📏 Goodness-of-fit (reduced χ²)', + f'{results.reduced_chi_square:.2f}', + ]) + for key, label in ( + ('rf', '📏 R-factor (Rf, %)'), + ('rf2', '📏 R-factor squared (Rf², %)'), + ('wr', '📏 Weighted R-factor (wR, %)'), + ('br', '📏 Bragg R-factor (BR, %)'), + ): + value = metrics.get(key) + if value is not None: + rows.append([label, f'{value:.2f}']) + if results.best_log_posterior is not None: + rows.append(['📉 Best log-posterior', f'{results.best_log_posterior:.2f}']) + + +def _append_convergence_rows( + *, + rows: list[list[str]], + diagnostics: dict[str, object], +) -> None: + """Append Bayesian convergence rows.""" + converged = diagnostics.get('converged') + if converged is not None: + rows.append(['📊 Convergence status', 'passed' if converged else 'failed']) + _append_optional_row(rows=rows, label='📊 Max r-hat', value=diagnostics.get('max_r_hat')) + _append_optional_row( + rows=rows, + label='📊 Min ess bulk', + value=diagnostics.get('min_ess_bulk'), + ) + _append_optional_row( + rows=rows, + label='📊 Draws per chain', + value=diagnostics.get('n_draws'), + precision=None, + ) + _append_optional_row( + rows=rows, + label='📊 Chains', + value=diagnostics.get('n_chains'), + precision=None, + ) + + +def _append_optional_row( + *, + rows: list[list[str]], + label: str, + value: object, + precision: int | None = 3, +) -> None: + """Append a formatted row when ``value`` is present.""" + if value is None: + return + if precision is None: + rows.append([label, str(value)]) + return + rows.append([label, f'{float(value):.{precision}f}']) def compute_convergence_diagnostics(posterior_samples: PosteriorSamples) -> dict[str, object]: @@ -354,12 +427,15 @@ def compute_convergence_diagnostics(posterior_samples: PosteriorSamples) -> dict dict[str, object] Convergence metrics keyed by diagnostic name. """ - inference_data = posterior_samples.to_arviz() - rhat_dataset = az.rhat(inference_data) - ess_dataset = az.ess(inference_data, method='bulk') + n_draws, n_chains, _n_parameters = posterior_samples.validate_shapes() + parameter_samples = np.asarray(posterior_samples.parameter_samples, dtype=float) - r_hat_by_parameter = _dataset_to_scalar_dict(rhat_dataset) - ess_bulk_by_parameter = _dataset_to_scalar_dict(ess_dataset) + r_hat_by_parameter: dict[str, float | None] = {} + ess_bulk_by_parameter: dict[str, float | None] = {} + for index, name in enumerate(posterior_samples.parameter_names): + per_parameter = parameter_samples[:, :, index] + r_hat_by_parameter[name] = _maybe_scalar(compute_r_hat(per_parameter)) + ess_bulk_by_parameter[name] = _maybe_scalar(compute_ess_bulk(per_parameter)) finite_r_hat = [value for value in r_hat_by_parameter.values() if value is not None] finite_ess_bulk = [value for value in ess_bulk_by_parameter.values() if value is not None] @@ -381,8 +457,8 @@ def compute_convergence_diagnostics(posterior_samples: PosteriorSamples) -> dict 'ess_bulk_by_parameter': ess_bulk_by_parameter, 'max_r_hat': max_r_hat, 'min_ess_bulk': min_ess_bulk, - 'n_draws': int(posterior_samples.parameter_samples.shape[0]), - 'n_chains': int(posterior_samples.parameter_samples.shape[1]), + 'n_draws': n_draws, + 'n_chains': n_chains, 'n_parameters': len(posterior_samples.parameter_names), } @@ -483,13 +559,6 @@ def standard_deviations_from_summaries( return np.array([summary.standard_deviation for summary in summaries], dtype=float) -def _dataset_to_scalar_dict(dataset: object) -> dict[str, float | None]: - values: dict[str, float | None] = {} - for name, data_array in dataset.data_vars.items(): - values[name] = _maybe_scalar(np.asarray(data_array).reshape(-1)[0]) - return values - - def _maybe_scalar(value: object) -> float | None: if value is None: return None @@ -499,18 +568,6 @@ def _maybe_scalar(value: object) -> float | None: return scalar -def _format_sampler_settings(sampler_settings: dict[str, object]) -> str | None: - if not sampler_settings: - return None - - parts = [ - f'{key}={sampler_settings[key]}' - for key in ('steps', 'burn', 'thin', 'pop', 'init', 'samples') - if key in sampler_settings - ] - return ', '.join(parts) if parts else None - - def _calculate_fit_quality_metrics( *, y_obs: list[float] | None, @@ -536,69 +593,41 @@ def _calculate_fit_quality_metrics( return metrics -def _print_fit_quality_metrics(metrics: dict[str, float | None]) -> None: - """Render any available fit-quality metrics.""" - metric_labels = ( - ('📏 R-factor (Rf)', metrics['rf']), - ('📏 R-factor squared (Rf²)', metrics['rf2']), - ('📏 Weighted R-factor (wR)', metrics['wr']), - ('📏 Bragg R-factor (BR)', metrics['br']), - ) - for label, value in metric_labels: - if value is not None: - console.print(f'{label}: {value:.2f}%') - - -def _format_point_estimate_name(point_estimate_name: str) -> str: - """Return a user-facing label for the committed point estimate.""" - normalized_name = point_estimate_name.strip().lower().replace('_', ' ') - if normalized_name in {'best sample', 'map'}: - return 'Best posterior sample' - return point_estimate_name.replace('_', ' ').title() - - -def _format_bayesian_overall_status( +def _bayesian_overall_status( *, success: bool, sampler_completed: bool, convergence_diagnostics: dict[str, object], -) -> tuple[str, str]: - """Return icon and text for Bayesian run status.""" - if not success: - return '❌', 'failed' +) -> str: + """ + Return ``'success'`` or ``'failed'`` for the Bayesian run. - converged = convergence_diagnostics.get('converged') + Bayesian success requires both the sampler to have completed and the + convergence diagnostics to have passed. Anything else is rendered as + ``failed`` in the overall row; the per-metric convergence rows below + carry the detail. + """ + if not success or not sampler_completed: + return 'failed' + converged = convergence_diagnostics.get('converged') if convergence_diagnostics else None if converged is False: - return '⚠️', 'completed with warnings' - if sampler_completed: - return '✅', 'completed' - return '✅', 'posterior available' - - -def _format_convergence_summary(convergence_diagnostics: dict[str, object]) -> str | None: - if not convergence_diagnostics: - return None - - parts: list[str] = [] - converged = convergence_diagnostics.get('converged') - if converged is not None: - status = 'passed' if converged else '[red]failed[/red]' - parts.append(f'status={status}') - - max_r_hat = _maybe_scalar(convergence_diagnostics.get('max_r_hat')) - if max_r_hat is not None: - parts.append(f'max_r_hat={_format_r_hat(max_r_hat)}') + return 'failed' + return 'success' - min_ess_bulk = _maybe_scalar(convergence_diagnostics.get('min_ess_bulk')) - if min_ess_bulk is not None: - parts.append(f'min_ess_bulk={_format_ess_bulk(min_ess_bulk)}') - n_draws = convergence_diagnostics.get('n_draws') - n_chains = convergence_diagnostics.get('n_chains') - if n_draws is not None and n_chains is not None: - parts.append(f'draws={n_draws}, chains={n_chains}') +_COMMITTED_PARAMETERS_FOOTNOTE: list[tuple[str, str]] = [ + ('start', 'parameter value before sampling'), + ('value', 'estimate written back to the project (best posterior sample)'), + ('s.u.', 'standard uncertainty (one sigma), posterior standard deviation'), + ('change', 'relative change from start, in %; ↑ = increase, ↓ = decrease'), +] - return ', '.join(parts) if parts else None +_POSTERIOR_DISTRIBUTION_FOOTNOTE: list[tuple[str, str]] = [ + ('median', '50th percentile of the marginal posterior'), + ('95% CI', '95% credible interval (2.5%-97.5%, asymmetric)'), + ('r-hat', 'Gelman-Rubin diagnostic (good convergence: r-hat <= 1.01)'), + ('ess bulk', 'bulk effective sample size (typically >= 400)'), +] def _render_committed_parameter_table(parameters: list[object]) -> None: @@ -609,8 +638,8 @@ def _render_committed_parameter_table(parameters: list[object]) -> None: 'parameter', 'units', 'start', - 'best posterior sample', - 'uncertainty', + 'value', + 's.u.', 'change', ] alignments = [ @@ -649,7 +678,7 @@ def _render_posterior_summary_table( 'parameter', 'units', 'median', - '95% interval', + '95% CI', 'r-hat', 'ess bulk', ] @@ -744,12 +773,13 @@ def _posterior_table_notes( notes: list[str] = [] if has_failed_r_hat: notes.append( - f'[red]r-hat > {R_HAT_CONVERGENCE_THRESHOLD:.2f}[/red]: ' - 'Consider longer sampling, better initialization, or reparameterization.' + f'⚠️ [red]r-hat > {R_HAT_CONVERGENCE_THRESHOLD:.2f}[/red]: ' + 'Consider longer sampling, better initialization, or ' + 'reparameterization.' ) if has_failed_ess_bulk: notes.append( - f'[red]ess bulk < {ESS_BULK_CONVERGENCE_THRESHOLD:.0f}[/red]: ' + f'⚠️ [red]ess bulk < {ESS_BULK_CONVERGENCE_THRESHOLD:.0f}[/red]: ' 'Consider longer sampling or reparameterization.' ) return notes diff --git a/src/easydiffraction/analysis/fit_helpers/reporting.py b/src/easydiffraction/analysis/fit_helpers/reporting.py index aefad27b6..40f898d86 100644 --- a/src/easydiffraction/analysis/fit_helpers/reporting.py +++ b/src/easydiffraction/analysis/fit_helpers/reporting.py @@ -7,10 +7,17 @@ from easydiffraction.analysis.fit_helpers.metrics import calculate_rb_factor from easydiffraction.analysis.fit_helpers.metrics import calculate_weighted_r_factor from easydiffraction.utils.logging import console -from easydiffraction.utils.logging import log +from easydiffraction.utils.utils import print_metrics_table +from easydiffraction.utils.utils import print_table_footnote from easydiffraction.utils.utils import render_table +def _overall_status_row_label(status: str) -> str: + """Return the metric label for an overall status row.""" + icon = '✅' if status == 'success' else '❌' + return f'{icon} Overall status' + + class FitResults: """ Container for results of a single optimization run. @@ -65,6 +72,7 @@ def __init__( starting_parameters if starting_parameters is not None else [] ) self.fitting_time: float | None = fitting_time + self.minimizer_type: str | None = None if 'redchi' in kwargs and self.reduced_chi_square is None: self.reduced_chi_square = kwargs.get('redchi') @@ -96,7 +104,6 @@ def display_results( f_calc : list[float] | None, default=None Calculated structure-factor magnitudes for Bragg R. """ - status_icon = '✅' if self.success else '❌' rf = rf2 = wr = br = None if y_obs is not None and y_calc is not None: rf = calculate_r_factor(y_obs, y_calc) * 100 @@ -106,21 +113,10 @@ def display_results( if f_obs is not None and f_calc is not None: br = calculate_rb_factor(f_obs, f_calc) * 100 - console.paragraph('Fit results') - console.print(f'{status_icon} Success: {self.success}') - fitting_time = _format_optional_float(self.fitting_time, suffix=' seconds') - goodness_of_fit = _format_optional_float(self.reduced_chi_square) - console.print(f'⏱️ Fitting time: {fitting_time}') - console.print(f'📏 Goodness-of-fit (reduced χ²): {goodness_of_fit}') - if rf is not None: - console.print(f'📏 R-factor (Rf): {rf:.2f}%') - if rf2 is not None: - console.print(f'📏 R-factor squared (Rf²): {rf2:.2f}%') - if wr is not None: - console.print(f'📏 Weighted R-factor (wR): {wr:.2f}%') - if br is not None: - console.print(f'📏 Bragg R-factor (BR): {br:.2f}%') - console.print('📈 Fitted parameters:') + console.print('📋 Least-squares fit results:') + print_metrics_table(self._build_fit_results_rows(rf=rf, rf2=rf2, wr=wr, br=br)) + + console.print('📈 Refined parameters:') headers = [ 'datablock', @@ -129,8 +125,8 @@ def display_results( 'parameter', 'units', 'start', - 'fitted', - 'uncertainty', + 'value', + 's.u.', 'change', ] alignments = [ @@ -153,23 +149,63 @@ def display_results( columns_data=rows, ) + print_table_footnote(_REFINED_PARAMETERS_FOOTNOTE) self._print_table_notes() + def _build_fit_results_rows( + self, + *, + rf: float | None, + rf2: float | None, + wr: float | None, + br: float | None, + ) -> list[list[str]]: + """Return the rows for the 'Least-squares fit results' table.""" + rows: list[list[str]] = [] + if self.minimizer_type is not None: + rows.append(['🧪 Minimizer', str(self.minimizer_type)]) + overall_status = 'success' if self.success else 'failed' + rows.append([_overall_status_row_label(overall_status), overall_status]) + if self.fitting_time is not None: + rows.append(['⏱️ Fitting time (seconds)', f'{self.fitting_time:.2f}']) + if self.iterations: + rows.append(['🔁 Iterations', str(self.iterations)]) + if self.reduced_chi_square is not None: + rows.append(['📏 Goodness-of-fit (reduced χ²)', f'{self.reduced_chi_square:.2f}']) + if rf is not None: + rows.append(['📏 R-factor (Rf, %)', f'{rf:.2f}']) + if rf2 is not None: + rows.append(['📏 R-factor squared (Rf², %)', f'{rf2:.2f}']) + if wr is not None: + rows.append(['📏 Weighted R-factor (wR, %)', f'{wr:.2f}']) + if br is not None: + rows.append(['📏 Bragg R-factor (BR, %)', f'{br:.2f}']) + return rows + def _print_table_notes(self) -> None: - """Print color-coded notes below the fitted parameters table.""" + """ + Print color-coded warnings below the refined parameters table. + """ notes: list[str] = [] if any(getattr(p, '_outside_physical_limits', False) for p in self.parameters): notes.append( - '[red]Red fitted value:[/red] outside expected physical limits (consider ' - 'adding constraints)' + '⚠️ [red]Red value:[/red] outside expected physical limits ' + '(consider adding constraints)' ) if any(_is_uncertainty_large(p) for p in self.parameters): notes.append( - '[red]Red uncertainty:[/red] exceeds the fitted value (consider adding ' - 'constraints)' + '⚠️ [red]Red s.u.:[/red] exceeds the refined value (consider adding constraints)' ) - for note in notes: - log.warning(note) + if notes: + console.small(*notes) + + +_REFINED_PARAMETERS_FOOTNOTE: list[tuple[str, str]] = [ + ('start', 'parameter value before refinement'), + ('value', 'refined value from least-squares minimization'), + ('s.u.', 'standard uncertainty (one sigma), from the covariance matrix'), + ('change', 'relative change from start, in %; ↑ = increase, ↓ = decrease'), +] def _is_uncertainty_large(param: object) -> bool: @@ -250,29 +286,3 @@ def _compute_relative_change(param: object) -> str: change = ((param.value - param._fit_start_value) / param._fit_start_value) * 100 arrow = '↑' if change > 0 else '↓' return f'{abs(change):.2f} % {arrow}' - - -def _format_optional_float( - value: float | None, - *, - suffix: str = '', -) -> str: - """ - Format an optional float for console output. - - Parameters - ---------- - value : float | None - Value to format. - suffix : str, default='' - Optional suffix appended to formatted numeric values. - - Returns - ------- - str - ``'N/A'`` when the value is ``None``; otherwise a formatted - string with two decimal places. - """ - if value is None: - return 'N/A' - return f'{value:.2f}{suffix}' diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index c7c79917f..7dd8c731d 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -3,12 +3,14 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any import numpy as np from easydiffraction.analysis.fit_helpers.metrics import get_reliability_inputs +from easydiffraction.analysis.minimizers.base import MinimizerFitOptions from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum from easydiffraction.analysis.minimizers.factory import MinimizerFactory from easydiffraction.core.variable import Parameter @@ -21,6 +23,26 @@ from easydiffraction.datablocks.structure.collection import Structures +@dataclass(frozen=True, slots=True) +class FitterFitOptions: + """Execution options for one fitter run.""" + + use_physical_limits: bool = False + random_seed: int | None = None + resume: bool = False + extra_steps: int | None = None + + def as_minimizer_options(self) -> MinimizerFitOptions: + """Return equivalent minimizer options for this fitter run.""" + return MinimizerFitOptions( + finalize_tracking=False, + use_physical_limits=self.use_physical_limits, + random_seed=self.random_seed, + resume=self.resume, + extra_steps=self.extra_steps, + ) + + def _resolve_fit_result_message(results: FitResults) -> str: """Return a normalized fit-result message.""" if results.message: @@ -127,6 +149,7 @@ def _postprocess_fit_results( self.results.message = _resolve_fit_result_message(self.results) self.results.iterations = _resolve_fit_result_iterations(self.results) self.results.chi_square = _resolve_fit_result_chi_square(self.results) + self.results.minimizer_type = self.selection if analysis is None: return @@ -145,8 +168,7 @@ def fit( analysis: object = None, verbosity: VerbosityEnum = VerbosityEnum.FULL, *, - use_physical_limits: bool = False, - random_seed: int | None = None, + options: FitterFitOptions | None = None, ) -> None: """ Run the fitting process. @@ -169,13 +191,16 @@ def fit( fitting. verbosity : VerbosityEnum, default=VerbosityEnum.FULL Console output verbosity. - use_physical_limits : bool, default=False - When ``True``, fall back to physical limits from the value - spec for parameters whose ``fit_min``/``fit_max`` are - unbounded. - random_seed : int | None, default=None - Optional random seed passed to stochastic minimizers. + options : FitterFitOptions | None, default=None + Execution options controlling limits, randomness and resume. + + Raises + ------ + ValueError + If resume is requested without the same free parameter set + used by the saved emcee chain. """ + fit_options = options or FitterFitOptions() # Enforce symmetry constraints (e.g. ADP) before collecting # free parameters so that components fixed by site symmetry are # excluded from the minimizer's parameter set. @@ -186,6 +211,9 @@ def fit( params = self._collect_fit_parameters(structures, experiments) if not params: + if fit_options.resume: + msg = 'Resume requires the same free parameters used by the saved emcee chain.' + raise ValueError(msg) if analysis is not None: analysis._clear_persisted_fit_state() analysis.fit_results = None @@ -193,8 +221,10 @@ def fit( print('⚠️ No parameters selected for fitting.') return - if analysis is not None: + if analysis is not None and not fit_options.resume: analysis._capture_fit_parameter_state(params) + if analysis is not None and fit_options.resume: + self._validate_resume_parameter_set(params=params, analysis=analysis) for param in params: param._fit_start_value = param.value @@ -207,6 +237,8 @@ def fit( analysis=analysis, ) + self._set_minimizer_sidecar_path(analysis) + try: # Keep tracker finalization in this layer so post-processing # can run before the live display is closed. @@ -214,23 +246,58 @@ def fit( params, objective_function, verbosity=verbosity, - finalize_tracking=False, - use_physical_limits=use_physical_limits, - random_seed=random_seed, + options=fit_options.as_minimizer_options(), ) - # Stop the timer and backfill results.fitting_time now so - # post-processing projects a real duration into persisted - # categories. The live display is still torn down in the - # finally below. - self.minimizer._finalize_timing() self._postprocess_fit_results( analysis=analysis, experiments=experiments, fitted_parameters=params, ) + # Keep the timer open through post-processing so the final + # sampler row and persisted fitting_time include the heavy + # Bayesian projection/cache work. + self.minimizer._finalize_timing() + self._backfill_persisted_fitting_time(analysis) finally: self.minimizer._stop_tracking() + def _set_minimizer_sidecar_path(self, analysis: object) -> None: + """Set the analysis results sidecar path when supported.""" + if analysis is None or not hasattr(self.minimizer, '_sidecar_path'): + return + + project_info = getattr(getattr(analysis, 'project', None), 'info', None) + project_path = getattr(project_info, 'path', None) + sidecar_path = None if project_path is None else project_path / 'analysis' / 'results.h5' + self.minimizer._sidecar_path = sidecar_path + + def _backfill_persisted_fitting_time(self, analysis: object) -> None: + """Update persisted fit-result time after post-processing.""" + if analysis is None or self.results is None: + return + fit_result = getattr(analysis, 'fit_result', None) + set_fitting_time = getattr(fit_result, '_set_fitting_time', None) + if callable(set_fitting_time): + set_fitting_time(self.results.fitting_time) + + @staticmethod + def _validate_resume_parameter_set( + *, + params: list[Parameter], + analysis: object, + ) -> None: + """Ensure resume uses the same persisted free-parameter set.""" + persisted_names = [ + item.param_unique_name.value for item in getattr(analysis, 'fit_parameters', []) + ] + if not persisted_names: + return + + current_names = [param.unique_name for param in params] + if persisted_names != current_names: + msg = 'Resume parameter set differs from the saved emcee chain; start a fresh run.' + raise ValueError(msg) + def _process_fit_results( self, structures: Structures, diff --git a/src/easydiffraction/analysis/minimizers/__init__.py b/src/easydiffraction/analysis/minimizers/__init__.py index 1006eefb7..cd3a1c144 100644 --- a/src/easydiffraction/analysis/minimizers/__init__.py +++ b/src/easydiffraction/analysis/minimizers/__init__.py @@ -7,6 +7,7 @@ from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer from easydiffraction.analysis.minimizers.bumps_lm import BumpsLmMinimizer from easydiffraction.analysis.minimizers.dfols import DfolsMinimizer +from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer from easydiffraction.analysis.minimizers.enums import DreamPopulationInitializationEnum from easydiffraction.analysis.minimizers.lmfit import LmfitMinimizer from easydiffraction.analysis.minimizers.lmfit_least_squares import LmfitLeastSquaresMinimizer diff --git a/src/easydiffraction/analysis/minimizers/base.py b/src/easydiffraction/analysis/minimizers/base.py index 438ed9eaa..9be83b6ad 100644 --- a/src/easydiffraction/analysis/minimizers/base.py +++ b/src/easydiffraction/analysis/minimizers/base.py @@ -4,6 +4,7 @@ from abc import ABC from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass from typing import Any import numpy as np @@ -16,6 +17,17 @@ BOUNDARY_PROXIMITY_FRACTION = 0.01 +@dataclass(frozen=True, slots=True) +class MinimizerFitOptions: + """Execution options for one minimizer run.""" + + finalize_tracking: bool = True + use_physical_limits: bool = False + random_seed: int | None = None + resume: bool = False + extra_steps: int | None = None + + class MinimizerBase(ABC): """ Abstract base for concrete minimizers. @@ -354,9 +366,7 @@ def fit( objective_function: Callable[..., object], verbosity: VerbosityEnum = VerbosityEnum.FULL, *, - finalize_tracking: bool = True, - use_physical_limits: bool = False, - random_seed: int | None = None, + options: MinimizerFitOptions | None = None, ) -> FitResults: """ Run the full minimization workflow. @@ -370,24 +380,31 @@ def fit( arguments. verbosity : VerbosityEnum, default=VerbosityEnum.FULL Console output verbosity. - finalize_tracking : bool, default=True - Whether to stop and finalize live tracking before returning. - use_physical_limits : bool, default=False - When ``True``, fall back to physical limits from the value - spec for parameters whose ``fit_min``/``fit_max`` are - unbounded. - random_seed : int | None, default=None - Optional random seed passed to stochastic minimizers. + options : MinimizerFitOptions | None, default=None + Execution options controlling limits, randomness, resume, + and tracker finalization. Returns ------- FitResults FitResults with success flag, best chi2 and timing. + + Raises + ------ + NotImplementedError + If resume is requested for a minimizer that does not support + it. """ - if use_physical_limits: + fit_options = options or MinimizerFitOptions() + if fit_options.resume: + minimizer_name = self.name or self.__class__.__name__ + msg = f"Minimizer '{minimizer_name}' does not support resume." + raise NotImplementedError(msg) + + if fit_options.use_physical_limits: self._apply_physical_limits(parameters) - resolved_random_seed = self._resolve_random_seed(random_seed) + resolved_random_seed = self._resolve_random_seed(fit_options.random_seed) minimizer_name = self.name or 'Unnamed Minimizer' if self.method is not None and f'({self.method})' not in minimizer_name: @@ -402,7 +419,7 @@ def fit( raw_result = self._run_solver(objective_function, **solver_args) return self._finalize_fit(parameters, raw_result) finally: - if finalize_tracking: + if fit_options.finalize_tracking: self._stop_tracking() def _objective_function( diff --git a/src/easydiffraction/analysis/minimizers/bumps_dream.py b/src/easydiffraction/analysis/minimizers/bumps_dream.py index 225c7ea0d..7bcdd5766 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_dream.py +++ b/src/easydiffraction/analysis/minimizers/bumps_dream.py @@ -737,6 +737,9 @@ def _build_driver( trim=DEFAULT_TRIM, ) driver.clip() + except KeyboardInterrupt: + MPMapper.stop_mapper() + raise except Exception: MPMapper.stop_mapper() raise diff --git a/src/easydiffraction/analysis/minimizers/emcee.py b/src/easydiffraction/analysis/minimizers/emcee.py new file mode 100644 index 000000000..d3be60b65 --- /dev/null +++ b/src/easydiffraction/analysis/minimizers/emcee.py @@ -0,0 +1,1271 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Minimizer using the emcee ensemble sampler.""" + +from __future__ import annotations + +import multiprocessing +import os +import pickle # noqa: S403 - used only to test whether multiprocessing can serialize a callable. +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import emcee +import numpy as np +from scipy.optimize import OptimizeResult + +from easydiffraction.analysis.fit_helpers.bayesian import BayesianFitResults +from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples +from easydiffraction.analysis.fit_helpers.bayesian import compute_convergence_diagnostics +from easydiffraction.analysis.fit_helpers.bayesian import standard_deviations_from_summaries +from easydiffraction.analysis.fit_helpers.bayesian import summarize_posterior_parameters +from easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square +from easydiffraction.analysis.fit_helpers.tracking import SamplerProgressUpdate +from easydiffraction.analysis.minimizers.base import MinimizerBase +from easydiffraction.analysis.minimizers.base import MinimizerFitOptions +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_INITIALIZATION_METHOD +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_METHOD +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_NBURN +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_NSTEPS +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_NWALKERS +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_PARALLEL_WORKERS +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_PROPOSAL_MOVES +from easydiffraction.analysis.minimizers.emcee_defaults import DEFAULT_THIN +from easydiffraction.analysis.minimizers.emcee_defaults import MAX_RANDOM_SEED +from easydiffraction.analysis.minimizers.emcee_defaults import SUPPORTED_INITIALIZATION_METHOD_SET +from easydiffraction.analysis.minimizers.emcee_defaults import SUPPORTED_INITIALIZATION_METHODS +from easydiffraction.analysis.minimizers.enums import InitializationMethodEnum +from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum +from easydiffraction.analysis.minimizers.factory import MinimizerFactory +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.utils.enums import VerbosityEnum + +EMCEE_CHAIN_GROUP = 'emcee_chain' +EMCEE_FAILURES = (ArithmeticError, RuntimeError, TypeError, ValueError) +EMCEE_SAMPLE_ARRAY_NDIM = 3 +TOTAL_PROGRESS_POINTS = 25 + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True, slots=True) +class _EmceePoolContext: + """Resolved emcee pool and log-probability callable.""" + + pool: object | None + log_prob_fn: Callable[[np.ndarray], float] + + +class _EmceeLogProbability: + """Pickle-aware emcee log-probability adapter.""" + + def __init__( + self, + *, + parameters: list[object], + parameter_names: list[str], + objective_function: Callable[[dict[str, object]], object], + ) -> None: + self._parameter_names = parameter_names + self._objective_function = objective_function + self._bounds = { + name: (float(parameter.fit_min), float(parameter.fit_max)) + for name, parameter in zip(parameter_names, parameters, strict=True) + } + + def __call__(self, theta: np.ndarray) -> float: + """Return log posterior for one walker position.""" + for name, value in zip(self._parameter_names, theta, strict=True): + lower_bound, upper_bound = self._bounds[name] + if not lower_bound <= float(value) <= upper_bound: + return -np.inf + + engine_params = { + name: float(value) for name, value in zip(self._parameter_names, theta, strict=True) + } + try: + residuals = np.asarray(self._objective_function(engine_params), dtype=float) + except Exception: # noqa: BLE001 - calculator failures make this proposal invalid. + return -np.inf + if residuals.size == 0 or not np.all(np.isfinite(residuals)): + return -np.inf + return -0.5 * float(np.sum(residuals**2)) + + +_EMCEE_WORKER_LOG_PROB: _EmceeLogProbability | None = None + + +def _set_emcee_worker_log_prob(log_prob: _EmceeLogProbability | None) -> None: + """Set the fork-inherited emcee worker log-probability callable.""" + global _EMCEE_WORKER_LOG_PROB # noqa: PLW0603 + _EMCEE_WORKER_LOG_PROB = log_prob + + +def _emcee_log_prob_worker(theta: np.ndarray) -> float: + """Evaluate log probability in an emcee multiprocessing worker.""" + if _EMCEE_WORKER_LOG_PROB is None: + msg = 'emcee worker log-probability callable has not been initialized.' + raise RuntimeError(msg) + return _EMCEE_WORKER_LOG_PROB(theta) + + +class _EmceeProgressReporter: + """ + Translate emcee iteration states into sampler progress rows. + """ + + def __init__( + self, + *, + tracker: object, + total_steps: int, + burn_steps: int, + ) -> None: + self._tracker = tracker + self._total_steps = max(1, total_steps) + self._burn_steps = min(max(0, burn_steps), self._total_steps) + burn_target_count, sampling_target_count = self._phase_progress_point_counts( + total_steps=self._total_steps, + burn_steps=self._burn_steps, + ) + self._burn_targets = self._progress_targets( + start=1, + stop=self._burn_steps, + target_count=burn_target_count, + ) + self._sampling_targets = self._progress_targets( + start=self._burn_steps + 1, + stop=self._total_steps, + target_count=sampling_target_count, + ) + self._next_burn_target_index = 0 + self._next_sampling_target_index = 0 + + def report(self, *, iteration: int, state: object) -> None: + """ + Forward one emcee state when it reaches a report target. + """ + clamped_iteration = min(max(1, iteration), self._total_steps) + if not self._should_report(clamped_iteration): + return + + self._tracker.track_sampler_progress( + SamplerProgressUpdate( + iteration=clamped_iteration, + total_iterations=self._total_steps, + phase=self._phase_name(clamped_iteration), + progress_percent=self._progress_percent(clamped_iteration), + log_posterior=self._log_posterior_from_state(state), + reduced_chi2=self._reduced_chi2_from_tracker(), + elapsed_time=self._tracker._current_elapsed_time(), + force_report=True, + ) + ) + + @staticmethod + def _phase_progress_point_counts( + *, + total_steps: int, + burn_steps: int, + ) -> tuple[int, int]: + """Return proportional burn and sampling progress counts.""" + total_points = min(TOTAL_PROGRESS_POINTS, max(1, total_steps)) + burn_steps = min(max(0, burn_steps), total_steps) + sampling_steps = max(total_steps - burn_steps, 0) + + if burn_steps == 0: + return 0, total_points + if sampling_steps == 0: + return total_points, 0 + + burn_target_count = round(total_points * burn_steps / total_steps) + burn_target_count = min( + max(burn_target_count, 1), + burn_steps, + total_points - 1, + ) + sampling_target_count = min( + max(total_points - burn_target_count, 1), + sampling_steps, + ) + return burn_target_count, sampling_target_count + + @staticmethod + def _progress_targets( + *, + start: int, + stop: int, + target_count: int, + ) -> list[int]: + """Return monotonically increasing reporting targets.""" + if target_count < 1 or stop < start: + return [] + + targets = np.linspace(start, stop, num=target_count) + rounded = np.rint(targets).astype(int) + unique_targets = sorted({int(value) for value in rounded if start <= value <= stop}) + if start not in unique_targets: + unique_targets.insert(0, start) + if stop not in unique_targets: + unique_targets.append(stop) + return unique_targets + + def _should_report(self, iteration: int) -> bool: + """Return whether this iteration should be rendered.""" + if self._phase_name(iteration) == 'burn-in': + return self._consume_progress_target( + iteration, + phase_targets=self._burn_targets, + target_index_name='_next_burn_target_index', + ) + + return self._consume_progress_target( + iteration, + phase_targets=self._sampling_targets, + target_index_name='_next_sampling_target_index', + ) + + def _consume_progress_target( + self, + iteration: int, + *, + phase_targets: list[int], + target_index_name: str, + ) -> bool: + """Advance a phase target pointer when iteration reaches it.""" + target_index = getattr(self, target_index_name) + should_report = False + while target_index < len(phase_targets) and iteration >= phase_targets[target_index]: + target_index += 1 + should_report = True + setattr(self, target_index_name, target_index) + return should_report + + def _phase_name(self, iteration: int) -> str: + """Return the current emcee phase name.""" + if iteration <= self._burn_steps: + return 'burn-in' + return 'sampling' + + def _progress_percent(self, iteration: int) -> float: + """Return emcee progress as a percentage.""" + return 100.0 * min(iteration, self._total_steps) / self._total_steps + + @staticmethod + def _log_posterior_from_state(state: object) -> float: + """Return the best finite log posterior from an emcee state.""" + log_probability = getattr(state, 'log_prob', None) + if log_probability is None: + return float('-inf') + + values = np.asarray(log_probability, dtype=float) + finite_values = values[np.isfinite(values)] + if finite_values.size == 0: + return float('-inf') + return float(np.max(finite_values)) + + def _reduced_chi2_from_tracker(self) -> float: + """Return the current best reduced chi-square, if available.""" + best_chi2 = getattr(self._tracker, 'best_chi2', None) + return float(best_chi2) if best_chi2 is not None else float('nan') + + +@MinimizerFactory.register +class EmceeMinimizer(MinimizerBase): + """emcee affine-invariant ensemble Bayesian sampler.""" + + type_info = TypeInfo( + tag=MinimizerTypeEnum.EMCEE, + description='emcee affine-invariant ensemble Bayesian sampling', + ) + + _sidecar_path: Path | None = None + + def __init__( + self, + name: str = MinimizerTypeEnum.EMCEE, + method: str = DEFAULT_METHOD, + max_iterations: int = DEFAULT_NSTEPS, + ) -> None: + super().__init__( + name=name, + method=method, + max_iterations=max_iterations, + ) + self._nburn: int = DEFAULT_NBURN + self._thin: int = DEFAULT_THIN + self._nwalkers: int = DEFAULT_NWALKERS + self._parallel_workers: int = DEFAULT_PARALLEL_WORKERS + self._initialization_method: InitializationMethodEnum = DEFAULT_INITIALIZATION_METHOD + self._proposal_moves: str = DEFAULT_PROPOSAL_MOVES + self._sampler: emcee.EnsembleSampler | None = None + self._backend: emcee.backends.HDFBackend | None = None + + @property + def nsteps(self) -> int: + """Number of emcee steps to run per walker.""" + return self._validated_positive_integer('nsteps', self._max_iterations) + + @nsteps.setter + def nsteps(self, value: int) -> None: + self._max_iterations = self._validated_positive_integer('nsteps', value) + + @property + def nburn(self) -> int: + """Number of initial emcee steps discarded as burn-in.""" + return self._nburn + + @nburn.setter + def nburn(self, value: int) -> None: + self._nburn = self._validated_non_negative_integer('nburn', value) + + @property + def thin(self) -> int: + """Emcee thinning interval.""" + return self._thin + + @thin.setter + def thin(self, value: int) -> None: + self._thin = self._validated_positive_integer('thin', value) + + @property + def nwalkers(self) -> int: + """Number of emcee walkers.""" + return self._nwalkers + + @nwalkers.setter + def nwalkers(self, value: int) -> None: + self._nwalkers = self._validated_positive_integer('nwalkers', value) + + @property + def parallel_workers(self) -> int: + """ + Worker count; ``0`` asks for all CPUs and ``1`` runs serially. + """ + return self._parallel_workers + + @parallel_workers.setter + def parallel_workers(self, value: int) -> None: + self._parallel_workers = self._validated_non_negative_integer('parallel_workers', value) + + @property + def initialization_method(self) -> InitializationMethodEnum: + """Emcee walker initialization method.""" + return self._initialization_method + + @initialization_method.setter + def initialization_method(self, value: InitializationMethodEnum | str) -> None: + self._initialization_method = self._validated_initialization_method(value) + + @property + def proposal_moves(self) -> str: + """Emcee proposal move name.""" + return self._proposal_moves + + @proposal_moves.setter + def proposal_moves(self, value: str) -> None: + self._proposal_moves = self._validated_proposal_moves(value) + + def fit( + self, + parameters: list[object], + objective_function: Callable[..., object], + verbosity: VerbosityEnum = VerbosityEnum.FULL, + *, + options: MinimizerFitOptions | None = None, + ) -> BayesianFitResults: + """ + Run emcee sampling and return Bayesian fit results. + """ + fit_options = options or MinimizerFitOptions() + if fit_options.use_physical_limits: + self._apply_physical_limits(parameters) + + resolved_random_seed = self._resolve_random_seed(fit_options.random_seed) + minimizer_name = self.name or 'emcee' + self._start_tracking(minimizer_name, verbosity=verbosity) + + try: + solver_args = self._prepare_solver_args(parameters) + solver_args['random_seed'] = resolved_random_seed + solver_args['resume'] = fit_options.resume + solver_args['extra_steps'] = fit_options.extra_steps + raw_result = self._run_solver(objective_function, **solver_args) + return self._finalize_fit(parameters, raw_result) + finally: + if fit_options.finalize_tracking: + self._stop_tracking() + + @staticmethod + def _tracking_mode() -> str: + """Use sampler-style progress reporting for emcee runs.""" + return 'sampling' + + def _resolve_random_seed(self, random_seed: int | None) -> int: + """ + Return a user-provided or generated random seed. + """ + if random_seed is None: + generator = np.random.default_rng() + random_seed = int(generator.integers(0, np.iinfo(np.int32).max)) + + integer_seed = self._validated_random_seed_value(random_seed) + self._resolved_random_seed = integer_seed + return self._resolved_random_seed + + @staticmethod + def _validated_random_seed_value(random_seed: object) -> int: + """Validate and normalize an emcee random seed.""" + if isinstance(random_seed, bool): + msg = f'emcee random_seed must be an integer between 0 and {MAX_RANDOM_SEED}.' + raise TypeError(msg) + + integer_seed = int(random_seed) + if integer_seed != random_seed or integer_seed < 0 or integer_seed > MAX_RANDOM_SEED: + msg = f'emcee random_seed must be an integer between 0 and {MAX_RANDOM_SEED}.' + raise ValueError(msg) + return integer_seed + + def _prepare_solver_args( + self, + parameters: list[object], + ) -> dict[str, object]: + """ + Prepare emcee solver arguments in EasyDiffraction order. + """ + self._validate_sampled_parameter_bounds(parameters) + return { + 'parameters': parameters, + 'parameter_names': [parameter.unique_name for parameter in parameters], + 'parameter_display_names': [ + getattr(parameter, 'name', parameter.unique_name) for parameter in parameters + ], + 'starting_values': np.array( + [parameter.value for parameter in parameters], + dtype=float, + ), + 'starting_uncertainties': [parameter.uncertainty for parameter in parameters], + } + + @classmethod + def _validate_sampled_parameter_bounds( + cls, + parameters: list[object], + ) -> None: + """ + Validate finite ordered bounds for sampled emcee parameters. + """ + issues: list[str] = [] + for parameter in parameters: + parameter_name = cls._parameter_name_for_bound_validation(parameter) + parameter_issues = cls._parameter_bound_issues(parameter) + if parameter_issues: + issues.append(f'- {parameter_name}: {"; ".join(parameter_issues)}') + + if not issues: + return + + message = 'emcee requires finite valid bounds for every sampled parameter:\n' + '\n'.join( + issues + ) + raise ValueError(message) + + @staticmethod + def _parameter_name_for_bound_validation(parameter: object) -> str: + """Return the user-facing name for emcee bound validation.""" + unique_name = getattr(parameter, 'unique_name', None) + if unique_name: + return str(unique_name) + + parameter_name = getattr(parameter, 'name', None) + if parameter_name: + return str(parameter_name) + return '' + + @classmethod + def _parameter_bound_issues( + cls, + parameter: object, + ) -> list[str]: + """Return bound-validation issues for one sampled parameter.""" + lower_bound = getattr(parameter, 'fit_min', None) + upper_bound = getattr(parameter, 'fit_max', None) + value = getattr(parameter, 'value', None) + issues: list[str] = [] + + lower_is_finite = cls._is_finite_bound_value(lower_bound) + upper_is_finite = cls._is_finite_bound_value(upper_bound) + value_is_finite = cls._is_finite_bound_value(value) + + if not lower_is_finite: + issues.append(f'fit_min must be finite (got {lower_bound!r})') + if not upper_is_finite: + issues.append(f'fit_max must be finite (got {upper_bound!r})') + + bounds_are_ordered = lower_is_finite and upper_is_finite and lower_bound < upper_bound + if lower_is_finite and upper_is_finite and not bounds_are_ordered: + issues.append(f'fit_min ({lower_bound}) must be smaller than fit_max ({upper_bound})') + + if not value_is_finite: + issues.append(f'starting value must be finite (got {value!r})') + elif bounds_are_ordered and not lower_bound <= value <= upper_bound: + issues.append(f'starting value {value} is outside [{lower_bound}, {upper_bound}]') + + return issues + + @staticmethod + def _is_finite_bound_value(value: object) -> bool: + """Return whether a bound-validation value is finite.""" + try: + return bool(np.isfinite(value)) + except TypeError: + return False + + @staticmethod + def _validated_positive_integer(name: str, value: float) -> int: + """Validate an emcee setting that must be a positive integer.""" + if isinstance(value, bool): + msg = f"emcee setting '{name}' must be a positive integer." + raise TypeError(msg) + + try: + integer_value = int(value) + except (TypeError, ValueError): + msg = f"emcee setting '{name}' must be a positive integer." + raise TypeError(msg) from None + if integer_value != value or integer_value < 1: + msg = f"emcee setting '{name}' must be a positive integer." + raise ValueError(msg) + return integer_value + + @staticmethod + def _validated_non_negative_integer(name: str, value: float) -> int: + """ + Validate an emcee setting that must be a non-negative integer. + """ + if isinstance(value, bool): + msg = f"emcee setting '{name}' must be a non-negative integer." + raise TypeError(msg) + + try: + integer_value = int(value) + except (TypeError, ValueError): + msg = f"emcee setting '{name}' must be a non-negative integer." + raise TypeError(msg) from None + if integer_value != value or integer_value < 0: + msg = f"emcee setting '{name}' must be a non-negative integer." + raise ValueError(msg) + return integer_value + + @staticmethod + def _validated_initialization_method( + value: InitializationMethodEnum | str, + ) -> InitializationMethodEnum: + """Validate an emcee initialization method.""" + try: + method = InitializationMethodEnum(value) + except ValueError: + valid_values = ', '.join( + initialization.value for initialization in SUPPORTED_INITIALIZATION_METHODS + ) + msg = f"emcee setting 'initialization_method' must be one of: {valid_values}." + raise ValueError(msg) from None + + if method not in SUPPORTED_INITIALIZATION_METHOD_SET: + valid_values = ', '.join( + initialization.value for initialization in SUPPORTED_INITIALIZATION_METHODS + ) + msg = f"emcee setting 'initialization_method' must be one of: {valid_values}." + raise ValueError(msg) + return method + + @staticmethod + def _validated_proposal_moves(value: str) -> str: + """Validate an emcee proposal move name.""" + valid_values = ('stretch', 'de', 'de_snooker', 'walk') + if value not in valid_values: + choices = ', '.join(valid_values) + msg = f"emcee setting 'proposal_moves' must be one of: {choices}." + raise ValueError(msg) + return value + + def _run_solver( + self, + objective_function: Callable[[dict[str, object]], object], + **kwargs: object, + ) -> object: + """ + Run emcee and normalize its posterior outputs. + """ + parameters = list(kwargs['parameters']) + parameter_names = list(kwargs['parameter_names']) + random_seed = int(kwargs['random_seed']) + resume = bool(kwargs.get('resume')) + extra_steps = kwargs.get('extra_steps') + + self._validate_walker_count(n_parameters=len(parameter_names)) + sidecar_path = self._resolved_sidecar_path() + sidecar_path.parent.mkdir(parents=True, exist_ok=True) + + total_iterations = self._resolved_total_iterations( + resume=resume, + extra_steps=extra_steps, + ) + self.tracker.start_sampler_pre_processing(total_iterations=total_iterations) + + backend = emcee.backends.HDFBackend( + str(sidecar_path), + name=EMCEE_CHAIN_GROUP, + read_only=False, + ) + self._backend = backend + + log_prob = self._build_log_probability( + parameters=parameters, + parameter_names=parameter_names, + objective_function=objective_function, + ) + pool_context = self._build_pool_context(log_prob) + terminate_pool = False + try: + sampler = self._run_sampler( + backend=backend, + log_prob=pool_context.log_prob_fn, + pool=pool_context.pool, + parameters=parameters, + n_parameters=len(parameter_names), + random_seed=random_seed, + resume=resume, + extra_steps=extra_steps, + total_iterations=total_iterations, + ) + except KeyboardInterrupt: + terminate_pool = True + raise + except EMCEE_FAILURES as error: + return self._failure_result( + message=f'emcee sampling failed: {error}', + starting_values=kwargs['starting_values'], + starting_uncertainties=kwargs['starting_uncertainties'], + sampler_settings=self._sampler_settings( + random_seed=random_seed, + total_steps=self._backend_iteration(backend), + n_parameters=len(parameter_names), + ), + raw_state=getattr(self._sampler, 'random_state', None), + sampler_completed=False, + ) + finally: + self._close_pool_context(pool_context, terminate=terminate_pool) + + result = self._build_success_result( + sampler=sampler, + backend=backend, + parameter_names=parameter_names, + parameter_display_names=list(kwargs['parameter_display_names']), + random_seed=random_seed, + starting_values=kwargs['starting_values'], + starting_uncertainties=kwargs['starting_uncertainties'], + ) + if getattr(result, 'success', False): + result.reduced_chi_square = self._best_sample_reduced_chi_square( + objective_function=objective_function, + parameter_names=parameter_names, + best_sample_values=np.asarray(result.x, dtype=float), + ) + self.tracker.start_sampler_post_processing() + return result + + def _run_sampler( # noqa: PLR0913 + self, + *, + backend: emcee.backends.HDFBackend, + log_prob: Callable[[np.ndarray], float], + pool: object | None, + parameters: list[object], + n_parameters: int, + random_seed: int, + resume: bool, + extra_steps: object, + total_iterations: int, + ) -> emcee.EnsembleSampler: + """Configure emcee, run sampling, and return the sampler.""" + if resume: + self._validate_resume( + backend=backend, + n_parameters=n_parameters, + extra_steps=extra_steps, + ) + else: + backend.reset(self.nwalkers, n_parameters) + + sampler = emcee.EnsembleSampler( + nwalkers=self.nwalkers, + ndim=n_parameters, + log_prob_fn=log_prob, + pool=pool, + moves=self._resolve_moves(self.proposal_moves), + backend=backend, + ) + self._sampler = sampler + + reporter = _EmceeProgressReporter( + tracker=self.tracker, + total_steps=total_iterations, + burn_steps=0 if resume else self.nburn, + ) + if resume: + initial_state = self._resume_initial_state(backend) + self._sample_with_progress( + sampler=sampler, + initial_state=initial_state, + iterations=int(extra_steps), + reporter=reporter, + skip_initial_state_check=True, + ) + return sampler + + initial_state = self._initial_state(parameters, random_seed=random_seed) + self._sample_with_progress( + sampler=sampler, + initial_state=initial_state, + iterations=total_iterations, + reporter=reporter, + skip_initial_state_check=False, + ) + return sampler + + @staticmethod + def _sample_with_progress( + *, + sampler: emcee.EnsembleSampler, + initial_state: object | None, + iterations: int, + reporter: _EmceeProgressReporter, + skip_initial_state_check: bool, + ) -> None: + """ + Run emcee one iteration at a time and report sampler progress. + """ + for iteration, state in enumerate( + sampler.sample( + initial_state, + iterations=iterations, + skip_initial_state_check=skip_initial_state_check, + progress=False, + ), + start=1, + ): + reporter.report(iteration=iteration, state=state) + + @staticmethod + def _backend_iteration(backend: object) -> int: + """Return backend iteration count, or zero when unavailable.""" + try: + return int(getattr(backend, 'iteration', 0)) + except (AttributeError, TypeError, ValueError): + return 0 + + @staticmethod + def _resume_initial_state(backend: object) -> object: + """Return the last persisted emcee state for resume runs.""" + try: + return backend.get_last_sample() + except AttributeError as exc: + msg = 'Existing emcee chain has no last sample; start a fresh run.' + raise ValueError(msg) from exc + + @staticmethod + def _build_log_probability( + *, + parameters: list[object], + parameter_names: list[str], + objective_function: Callable[[dict[str, object]], object], + ) -> _EmceeLogProbability: + """Return an emcee log-probability adapter.""" + return _EmceeLogProbability( + parameters=parameters, + parameter_names=parameter_names, + objective_function=objective_function, + ) + + def _resolved_sidecar_path(self) -> Path: + """Return the HDF sidecar path required by the emcee backend.""" + if self._sidecar_path is None: + msg = 'emcee engine requires Fitter.fit to set _sidecar_path; was Analysis configured?' + raise RuntimeError(msg) + return Path(self._sidecar_path) + + def _resolved_total_iterations( + self, + *, + resume: bool, + extra_steps: object, + ) -> int: + """Return the total iterations expected for progress display.""" + if not resume: + return self.nsteps + self.nburn + 1 + return self._validated_positive_integer('extra_steps', extra_steps) + + def _validate_walker_count(self, *, n_parameters: int) -> None: + """Validate emcee's minimum walker count for red-blue moves.""" + if n_parameters < 1: + msg = 'emcee requires at least one sampled parameter.' + raise ValueError(msg) + minimum_walkers = 2 * n_parameters + if self.nwalkers < minimum_walkers: + msg = ( + f"emcee setting 'nwalkers' must be at least twice the sampled " + f'parameter count ({minimum_walkers}).' + ) + raise ValueError(msg) + + def _validate_resume( + self, + *, + backend: object, + n_parameters: int, + extra_steps: object, + ) -> None: + """Validate that an existing emcee backend can be resumed.""" + self._validated_positive_integer('extra_steps', extra_steps) + iteration = self._backend_iteration(backend) + if iteration < 1: + msg = 'No existing emcee chain was found; start a fresh run instead.' + raise ValueError(msg) + + backend_shape = getattr(backend, 'shape', None) + if backend_shape != (self.nwalkers, n_parameters): + msg = ( + 'Existing emcee chain shape does not match current parameters; start a fresh run.' + ) + raise ValueError(msg) + + def _build_pool_context(self, log_prob: _EmceeLogProbability) -> _EmceePoolContext: + """ + Build an emcee map pool for the configured parallel setting. + """ + workers = self.parallel_workers + if workers == 1: + return _EmceePoolContext(pool=None, log_prob_fn=log_prob) + + worker_count = os.cpu_count() if workers == 0 else workers + if worker_count is None or worker_count <= 1: + return _EmceePoolContext(pool=None, log_prob_fn=log_prob) + + if self._can_pickle(log_prob): + return _EmceePoolContext( + pool=multiprocessing.Pool(worker_count), + log_prob_fn=log_prob, + ) + + if self._fork_context_available(): + try: + _set_emcee_worker_log_prob(log_prob) + pool = multiprocessing.get_context('fork').Pool(worker_count) + except (OSError, RuntimeError): + _set_emcee_worker_log_prob(None) + else: + return _EmceePoolContext( + pool=pool, + log_prob_fn=_emcee_log_prob_worker, + ) + + self._warn_after_tracking( + 'emcee parallel evaluation requires either a picklable objective ' + 'or fork-based multiprocessing; falling back to serial execution.' + ) + return _EmceePoolContext(pool=None, log_prob_fn=log_prob) + + @staticmethod + def _close_pool_context( + pool_context: _EmceePoolContext, + *, + terminate: bool = False, + ) -> None: + """ + Close a resolved emcee pool and clear inherited worker state. + """ + pool = pool_context.pool + try: + EmceeMinimizer._shutdown_pool(pool, terminate=terminate) + finally: + _set_emcee_worker_log_prob(None) + + @staticmethod + def _shutdown_pool(pool: object | None, *, terminate: bool) -> None: + """Close or terminate an emcee multiprocessing pool.""" + if pool is None: + return + if terminate: + pool.terminate() + else: + pool.close() + pool.join() + + @staticmethod + def _can_pickle(value: object) -> bool: + """ + Return whether a value can be serialized by multiprocessing. + """ + try: + pickle.dumps(value) + except (AttributeError, TypeError, pickle.PickleError): + return False + return True + + @staticmethod + def _fork_context_available() -> bool: + """Return whether fork-based multiprocessing is available.""" + return os.name != 'nt' and 'fork' in multiprocessing.get_all_start_methods() + + @staticmethod + def _resolve_moves(proposal_moves: str) -> object: + """Return the emcee move object for a persisted move name.""" + if proposal_moves == 'stretch': + return emcee.moves.StretchMove() + if proposal_moves == 'de': + return emcee.moves.DEMove() + if proposal_moves == 'de_snooker': + return emcee.moves.DESnookerMove() + if proposal_moves == 'walk': + return emcee.moves.WalkMove() + msg = f"Unsupported emcee proposal move '{proposal_moves}'." + raise ValueError(msg) + + def _initial_state( + self, + parameters: list[object], + *, + random_seed: int, + ) -> np.ndarray: + """Build an initial walker state for emcee.""" + rng = np.random.default_rng(random_seed) + lower = np.array([float(parameter.fit_min) for parameter in parameters], dtype=float) + upper = np.array([float(parameter.fit_max) for parameter in parameters], dtype=float) + center = np.array([float(parameter.value) for parameter in parameters], dtype=float) + + if self.initialization_method == InitializationMethodEnum.BALL: + scale = np.maximum((upper - lower) * 1.0e-4, np.finfo(float).eps) + initial = center + rng.normal(scale=scale, size=(self.nwalkers, len(parameters))) + return np.clip(initial, lower, upper) + + return rng.uniform(lower, upper, size=(self.nwalkers, len(parameters))) + + def _sampler_settings( + self, + *, + random_seed: int, + total_steps: int, + n_parameters: int, + ) -> dict[str, object]: + """Build sampler settings recorded in results.""" + samples = self.nsteps * self.nwalkers * n_parameters + return { + 'random_seed': int(random_seed), + 'thin': int(self.thin), + 'samples': int(samples), + 'total_steps': int(total_steps), + 'nsteps': int(self.nsteps), + 'nburn': int(self.nburn), + 'nwalkers': int(self.nwalkers), + 'parallel_workers': int(self.parallel_workers), + 'initialization_method': self.initialization_method.value, + 'proposal_moves': self.proposal_moves, + } + + @staticmethod + def _failure_result( + *, + message: str, + starting_values: object, + starting_uncertainties: object, + sampler_settings: dict[str, object], + raw_state: object, + sampler_completed: bool, + ) -> OptimizeResult: + """ + Build a normalized failure result for an incomplete emcee run. + """ + return OptimizeResult( + x=np.asarray(starting_values, dtype=float), + dx=None, + fun=None, + success=False, + status=-1, + message=message, + var_names=[], + posterior_samples=None, + posterior_parameter_summaries=[], + convergence_diagnostics={}, + sampler_settings=sampler_settings, + sampler_completed=sampler_completed, + raw_state=raw_state, + best_log_posterior=None, + starting_values=np.asarray(starting_values, dtype=float), + starting_uncertainties=list(starting_uncertainties), + ) + + def _build_success_result( # noqa: PLR0914 + self, + *, + sampler: emcee.EnsembleSampler, + backend: emcee.backends.HDFBackend, + parameter_names: list[str], + parameter_display_names: list[str], + random_seed: int, + starting_values: object, + starting_uncertainties: object, + ) -> OptimizeResult: + """Normalize a completed emcee run into an OptimizeResult.""" + total_steps = self._backend_iteration(backend) + discard = min(self.nburn, max(total_steps - 1, 0)) + chain = np.asarray(sampler.get_chain(discard=discard, thin=self.thin), dtype=float) + log_posterior = np.asarray( + sampler.get_log_prob(discard=discard, thin=self.thin), + dtype=float, + ) + sampler_settings = self._sampler_settings( + random_seed=random_seed, + total_steps=total_steps, + n_parameters=len(parameter_names), + ) + + if ( + chain.ndim != EMCEE_SAMPLE_ARRAY_NDIM + or chain.size == 0 + or log_posterior.shape != chain.shape[:2] + ): + return self._failure_result( + message='emcee sampling did not return usable posterior samples.', + starting_values=starting_values, + starting_uncertainties=starting_uncertainties, + sampler_settings=sampler_settings, + raw_state=sampler.get_last_sample(), + sampler_completed=True, + ) + + finite_log_posterior = np.where(np.isfinite(log_posterior), log_posterior, -np.inf) + if not np.any(np.isfinite(finite_log_posterior)): + return self._failure_result( + message='emcee sampling did not return any finite log-posterior values.', + starting_values=starting_values, + starting_uncertainties=starting_uncertainties, + sampler_settings=sampler_settings, + raw_state=sampler.get_last_sample(), + sampler_completed=True, + ) + + best_flat_index = int(np.argmax(finite_log_posterior)) + best_draw_index, best_walker_index = np.unravel_index( + best_flat_index, + finite_log_posterior.shape, + ) + best_sample_values = np.asarray(chain[best_draw_index, best_walker_index, :], dtype=float) + draw_index = np.arange(chain.shape[0], dtype=float) * self.thin + discard + 1 + posterior_samples = PosteriorSamples( + parameter_names=parameter_names, + parameter_samples=chain, + log_posterior=log_posterior, + draw_index=draw_index, + ) + convergence_diagnostics = self._convergence_diagnostics( + posterior_samples=posterior_samples, + sampler=sampler, + ) + posterior_parameter_summaries = summarize_posterior_parameters( + parameter_names=parameter_names, + posterior_samples=posterior_samples, + best_sample_values=best_sample_values, + parameter_display_names=parameter_display_names, + convergence_diagnostics=convergence_diagnostics, + ) + posterior_standard_deviations = standard_deviations_from_summaries( + posterior_parameter_summaries + ) + best_log_posterior = float(finite_log_posterior[best_draw_index, best_walker_index]) + self._track_sampler_completion( + total_steps=total_steps, + best_log_posterior=best_log_posterior, + reduced_chi_square=None, + ) + + return OptimizeResult( + x=best_sample_values, + dx=posterior_standard_deviations, + fun=-best_log_posterior, + success=True, + status=0, + message='emcee sampling completed', + var_names=parameter_names, + posterior_samples=posterior_samples, + posterior_parameter_summaries=posterior_parameter_summaries, + convergence_diagnostics=convergence_diagnostics, + sampler_settings=sampler_settings, + sampler_completed=True, + raw_state=sampler.get_last_sample(), + best_log_posterior=best_log_posterior, + starting_values=np.asarray(starting_values, dtype=float), + starting_uncertainties=list(starting_uncertainties), + ) + + @staticmethod + def _best_sample_reduced_chi_square( + *, + objective_function: Callable[[dict[str, object]], object], + parameter_names: list[str], + best_sample_values: np.ndarray, + ) -> float | None: + """Evaluate reduced chi-square at the committed sample.""" + engine_params = { + name: float(value) + for name, value in zip(parameter_names, best_sample_values, strict=True) + } + try: + residuals = np.asarray(objective_function(engine_params), dtype=float) + except Exception: # noqa: BLE001 - calculator failures leave chi-square unknown. + return None + if residuals.size == 0 or not np.all(np.isfinite(residuals)): + return None + return calculate_reduced_chi_square(residuals, len(parameter_names)) + + def _convergence_diagnostics( + self, + *, + posterior_samples: PosteriorSamples, + sampler: emcee.EnsembleSampler, + ) -> dict[str, object]: + """ + Compute convergence diagnostics and add emcee acceptance rate. + """ + try: + convergence_diagnostics = compute_convergence_diagnostics(posterior_samples) + except (TypeError, ValueError, RuntimeError) as error: + self._warn_after_tracking( + f'emcee convergence diagnostics could not be computed: {error}' + ) + convergence_diagnostics = { + 'converged': False, + 'r_hat_by_parameter': {}, + 'ess_bulk_by_parameter': {}, + 'max_r_hat': None, + 'min_ess_bulk': None, + 'n_draws': int(posterior_samples.parameter_samples.shape[0]), + 'n_chains': int(posterior_samples.parameter_samples.shape[1]), + 'n_parameters': len(posterior_samples.parameter_names), + } + + acceptance_fraction = np.asarray(sampler.acceptance_fraction, dtype=float) + finite_acceptance = acceptance_fraction[np.isfinite(acceptance_fraction)] + convergence_diagnostics['acceptance_rate_mean'] = ( + float(np.mean(finite_acceptance)) if finite_acceptance.size else None + ) + if not convergence_diagnostics.get('converged', True): + self._warn_after_tracking( + 'Convergence diagnostics indicate the posterior may be poorly mixed.' + ) + return convergence_diagnostics + + def _track_sampler_completion( + self, + *, + total_steps: int, + best_log_posterior: float, + reduced_chi_square: float | None, + ) -> None: + """Record one final sampler progress row.""" + reduced_chi2 = reduced_chi_square + if reduced_chi2 is None: + reduced_chi2 = self.tracker.best_chi2 + if reduced_chi2 is None: + reduced_chi2 = np.nan + self.tracker.track_sampler_progress( + SamplerProgressUpdate( + iteration=max(1, total_steps), + total_iterations=max(1, total_steps), + phase='sampling', + progress_percent=100.0, + log_posterior=best_log_posterior, + reduced_chi2=float(reduced_chi2), + elapsed_time=self.tracker._current_elapsed_time(), + force_report=True, + ) + ) + + @staticmethod + def _sync_result_to_parameters( + parameters: list[object], + raw_result: object, + ) -> None: + """ + Sync proposed or best posterior values to live parameters. + """ + if hasattr(raw_result, 'x'): + if getattr(raw_result, 'success', False): + values = raw_result.x + uncertainties = getattr(raw_result, 'dx', None) + else: + values = getattr(raw_result, 'starting_values', raw_result.x) + uncertainties = getattr(raw_result, 'starting_uncertainties', None) + elif isinstance(raw_result, dict): + for parameter in parameters: + value = raw_result.get(parameter.unique_name) + if value is None: + value = raw_result.get(getattr(parameter, '_minimizer_uid', '')) + if value is not None: + parameter._set_value_from_minimizer(float(value)) + return + else: + values = raw_result + uncertainties = None + + if values is None: + return + + for index, parameter in enumerate(parameters): + parameter._set_value_from_minimizer(float(values[index])) + if uncertainties is None: + parameter.uncertainty = None + continue + + uncertainty = uncertainties[index] + parameter.uncertainty = None if uncertainty is None else float(uncertainty) + + def _build_fit_results( + self, + *, + parameters: list[object], + raw_result: object, + success: bool, + ) -> BayesianFitResults: + """ + Build the Bayesian fit result container. + """ + fit_results = BayesianFitResults( + success=success, + parameters=parameters, + reduced_chi_square=getattr(raw_result, 'reduced_chi_square', self.tracker.best_chi2), + engine_result=getattr(raw_result, 'raw_state', raw_result), + starting_parameters=parameters, + fitting_time=self.tracker.fitting_time, + sampler_name='emcee', + point_estimate_name='best_sample', + posterior_samples=getattr(raw_result, 'posterior_samples', None), + posterior_parameter_summaries=getattr(raw_result, 'posterior_parameter_summaries', []), + posterior_predictive={}, + credible_interval_levels=(0.68, 0.95), + sampler_settings=getattr(raw_result, 'sampler_settings', {}), + convergence_diagnostics=getattr(raw_result, 'convergence_diagnostics', {}), + sampler_completed=getattr(raw_result, 'sampler_completed', False), + best_log_posterior=getattr(raw_result, 'best_log_posterior', None), + ) + fit_results.message = getattr(raw_result, 'message', '') + fit_results.iterations = int(fit_results.sampler_settings.get('nsteps', self.nsteps)) + fit_results.result = raw_result + return fit_results + + def _check_success(self, raw_result: object) -> bool: # noqa: PLR6301 + """Determine success from normalized emcee result.""" + return bool(getattr(raw_result, 'success', False)) diff --git a/src/easydiffraction/analysis/minimizers/emcee_defaults.py b/src/easydiffraction/analysis/minimizers/emcee_defaults.py new file mode 100644 index 000000000..6f590660f --- /dev/null +++ b/src/easydiffraction/analysis/minimizers/emcee_defaults.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Shared defaults for the emcee minimizer.""" + +from __future__ import annotations + +import numpy as np + +from easydiffraction.analysis.minimizers.enums import InitializationMethodEnum + +DEFAULT_METHOD = 'de' +DEFAULT_NSTEPS = 5000 +DEFAULT_NBURN = 1000 +DEFAULT_THIN = 1 +DEFAULT_NWALKERS = 32 +DEFAULT_PARALLEL_WORKERS = 0 +DEFAULT_INITIALIZATION_METHOD = InitializationMethodEnum.BALL +DEFAULT_PROPOSAL_MOVES = 'de' +MAX_RANDOM_SEED = int(np.iinfo(np.uint32).max) +SUPPORTED_PROPOSAL_MOVES = ('stretch', 'de', 'de_snooker', 'walk') +SUPPORTED_INITIALIZATION_METHODS = ( + InitializationMethodEnum.BALL, + InitializationMethodEnum.UNIFORM, + InitializationMethodEnum.PRIOR, +) +SUPPORTED_INITIALIZATION_METHOD_SET = frozenset(SUPPORTED_INITIALIZATION_METHODS) diff --git a/src/easydiffraction/analysis/minimizers/enums.py b/src/easydiffraction/analysis/minimizers/enums.py index 6931960e0..272861835 100644 --- a/src/easydiffraction/analysis/minimizers/enums.py +++ b/src/easydiffraction/analysis/minimizers/enums.py @@ -19,6 +19,7 @@ class MinimizerTypeEnum(StrEnum): BUMPS_DREAM = 'bumps (dream)' BUMPS_AMOEBA = 'bumps (amoeba)' BUMPS_DE = 'bumps (de)' + EMCEE = 'emcee' @classmethod def default(cls) -> MinimizerTypeEnum: @@ -49,6 +50,7 @@ def description(self) -> str: MinimizerTypeEnum.BUMPS_DREAM: ('BUMPS library with DREAM Bayesian sampling'), MinimizerTypeEnum.BUMPS_AMOEBA: ('BUMPS library with Nelder-Mead simplex method'), MinimizerTypeEnum.BUMPS_DE: ('BUMPS library with differential evolution method'), + MinimizerTypeEnum.EMCEE: ('emcee affine-invariant ensemble Bayesian sampling'), } return descriptions.get(self, '') diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py index ce9fe54b2..a6b338051 100644 --- a/src/easydiffraction/analysis/sequential.py +++ b/src/easydiffraction/analysis/sequential.py @@ -26,6 +26,7 @@ from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log from easydiffraction.utils.utils import build_table_renderable +from easydiffraction.utils.utils import display_path # ------------------------------------------------------------------ # Template dataclass (picklable for ProcessPoolExecutor) @@ -893,7 +894,7 @@ def _print_sequential_completion( return console.print(f'✅ Sequential fitting complete: {processed_count} files processed.') - console.print(f'📄 Results saved to:\n{csv_path}') + console.print(f"📄 Results saved to '{display_path(csv_path)}'") def _prepare_sequential_run( diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index b0120d0de..ee1de7fda 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -30,6 +30,7 @@ from easydiffraction.io.cif.serialize import experiment_to_cif from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log +from easydiffraction.utils.utils import format_bulleted_warning from easydiffraction.utils.utils import render_cif if TYPE_CHECKING: @@ -617,13 +618,12 @@ def _replace_peak_profile( log.warning(msg) return - if self._peak is not None and announce: - log.warning( - 'Switching peak profile type discards existing peak parameters.', - ) - old_peak = self._peak - self._peak = PeakFactory.create(canonical_type) + new_peak = PeakFactory.create(canonical_type) + if old_peak is not None and announce: + self._warn_about_peak_profile_swap(old_peak, new_peak) + + self._peak = new_peak if old_peak is not None: old_peak._parent = None self._peak._parent = self @@ -673,3 +673,62 @@ def _restore_switchable_types(self, block: object) -> None: peak_type = read_cif_str(block, '_peak.type') if peak_type is not None: self._set_peak_profile_type(peak_type) + + @staticmethod + def _peak_parameter_values(peak: object) -> dict[str, object]: + """Return peak parameter values excluding the selector type.""" + return { + parameter.name: parameter.value + for parameter in getattr(peak, 'parameters', []) + if parameter.name != 'type' + } + + @classmethod + def _peak_profile_swap_diff( + cls, + old_peak: object, + new_peak: object, + ) -> tuple[list[str], list[str], list[str]]: + """Return removed, added, and reset peak-setting rows.""" + old_values = cls._peak_parameter_values(old_peak) + new_values = cls._peak_parameter_values(new_peak) + old_keys = set(old_values) + new_keys = set(new_values) + removed = sorted(old_keys - new_keys) + added = sorted(f'{name}={new_values[name]!r}' for name in (new_keys - old_keys)) + reset = sorted( + f'{name}: {old_values[name]!r} -> {new_values[name]!r}' + for name in (old_keys & new_keys) + if old_values[name] != new_values[name] + ) + return removed, added, reset + + @classmethod + def _warn_about_peak_profile_swap( + cls, + old_peak: object, + new_peak: object, + ) -> None: + """Warn about peak settings changed by a profile swap.""" + removed, added, reset = cls._peak_profile_swap_diff(old_peak, new_peak) + if removed: + log.warning( + format_bulleted_warning( + 'Switching peak profile type removes these settings:', + removed, + ) + ) + if added: + log.warning( + format_bulleted_warning( + 'Switching peak profile type adds these settings with defaults:', + added, + ) + ) + if reset: + log.warning( + format_bulleted_warning( + 'Switching peak profile type resets these settings to defaults:', + reset, + ) + ) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index f96c812a4..ce838dbdd 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -19,6 +19,7 @@ from easydiffraction.analysis.enums import FitCorrelationSourceEnum from easydiffraction.analysis.enums import FitResultKindEnum from easydiffraction.analysis.fit_helpers.bayesian import PosteriorPredictiveSummary +from easydiffraction.analysis.fit_helpers.bayesian import posterior_predictive_cache_key from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum @@ -85,7 +86,7 @@ class PosteriorPairPlotStyleEnum(StrEnum): DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION = 0.10 DEFAULT_RESID_HEIGHT = DEFAULT_RESIDUAL_HEIGHT_FRACTION DEFAULT_BRAGG_ROW = DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION -DEFAULT_POSTERIOR_PREDICTIVE_DRAWS = 200 +DEFAULT_POSTERIOR_PREDICTIVE_DRAWS = 50 DEFAULT_POSTERIOR_PREDICTIVE_DRAW_PLOT_CAP = 50 FULL_POSTERIOR_PAIR_COVARIANCE_RANK = 2 POSTERIOR_FLATTENED_SAMPLE_NDIM = 2 @@ -3542,12 +3543,12 @@ def _get_or_build_posterior_predictive_summary( return None x_axis_name = getattr(x_axis, 'value', x_axis) - draw_cache_key = self._posterior_predictive_key( + draw_cache_key = posterior_predictive_cache_key( expt_name, str(x_axis_name), include_draws=True, ) - band_cache_key = self._posterior_predictive_key( + band_cache_key = posterior_predictive_cache_key( expt_name, str(x_axis_name), include_draws=False, @@ -3819,44 +3820,6 @@ def _posterior_predictive_draw_indices(n_draws: int) -> np.ndarray: ) ) - @staticmethod - def _posterior_predictive_key( - expt_name: str, - x_axis_name: str, - *, - include_draws: bool = True, - ) -> str: - """Return the cache key for a posterior predictive summary.""" - key_suffix = 'draws' if include_draws else 'band' - return f'{expt_name}:{x_axis_name}:{key_suffix}' - - def _get_posterior_inference_data( - self, - ) -> tuple[object | None, object | None]: - """ - Return posterior inference data for the current Bayesian fit. - - Returns - ------- - tuple[object | None, object | None] - ``(inference_data, fit_results)`` when posterior samples are - available, otherwise ``(None, None)``. - """ - if self.engine != PlotterEngineEnum.PLOTLY.value: - log.warning('Posterior plots currently require the Plotly plotting backend.') - return None, None - - fit_results = self._get_fit_result_for_correlation() - if fit_results is None: - return None, None - - posterior_samples = getattr(fit_results, 'posterior_samples', None) - if posterior_samples is None: - log.warning('Posterior samples are unavailable. Run a Bayesian fit first.') - return None, None - - return posterior_samples.to_arviz(), fit_results - def _get_posterior_samples_and_fit_results( self, ) -> tuple[object | None, object | None]: diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py index 6123e626e..1a9a73bc4 100644 --- a/src/easydiffraction/display/progress.py +++ b/src/easydiffraction/display/progress.py @@ -5,10 +5,13 @@ from __future__ import annotations import html +import uuid from contextlib import AbstractContextManager from contextlib import suppress +from pathlib import Path from time import monotonic from typing import TYPE_CHECKING +from typing import Self if TYPE_CHECKING: from types import TracebackType @@ -16,9 +19,13 @@ try: from IPython.display import HTML from IPython.display import DisplayHandle + from IPython.display import Javascript + from IPython.display import display except ImportError: # pragma: no cover - optional dependency HTML = None DisplayHandle = None + Javascript = None + display = None from rich.console import Group from rich.live import Live @@ -489,3 +496,274 @@ def activity_indicator( on exit. """ return _ActivityIndicatorContext(label=label, verbosity=verbosity) + + +class NotebookFitStopControl(AbstractContextManager): + """Display a Jupyter stop button for fitting runs.""" + + def __init__(self, *, verbosity: VerbosityEnum) -> None: + self._verbosity = verbosity + self._display_handle: object | None = None + self._element_id = f'ed-fit-stop-{uuid.uuid4().hex}' + self._kernel_id = self._current_kernel_id() + + def __enter__(self) -> Self: + """Show the stop button.""" + self.show() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Update or clear the stop button when leaving the context.""" + del exc_type + del exc_value + del traceback + self.close() + + def show(self) -> None: + """Render the stop button when running in a notebook.""" + if not self._can_display(): + return + + handle = DisplayHandle() + self._display_handle = handle + with suppress(Exception): + handle.display(HTML(self._active_html())) + display(Javascript(self._interrupt_javascript())) + + def close(self) -> None: + """Clear the stop button when fitting ends.""" + if self._display_handle is None or HTML is None: + return + + with suppress(Exception): + self._display_handle.update(HTML('')) + self._display_handle = None + + def _can_display(self) -> bool: + return ( + self._verbosity is not VerbosityEnum.SILENT + and in_jupyter() + and DisplayHandle is not None + and HTML is not None + and Javascript is not None + and display is not None + ) + + def _active_html(self) -> str: + return ( + '' + f'
' + f'' + f'' + '
' + ) + + def _interrupt_javascript(self) -> str: + button_id = f'{self._element_id}-button' + status_id = f'{self._element_id}-status' + kernel_id = self._kernel_id + return f""" +(function() {{ + const button = document.getElementById({button_id!r}); + const status = document.getElementById({status_id!r}); + const kernelId = {kernel_id!r}; + if (!button) {{ + return; + }} + + function setStatus(text) {{ + if (status) {{ + status.textContent = text; + }} + }} + + function pageConfig() {{ + const element = document.getElementById('jupyter-config-data'); + if (!element || !element.textContent) {{ + return {{}}; + }} + try {{ + return JSON.parse(element.textContent); + }} catch (error) {{ + return {{}}; + }} + }} + + function baseUrl(config) {{ + const configured = config.baseUrl || config.base_url || + (window.Jupyter && Jupyter.notebook && Jupyter.notebook.base_url); + if (configured) {{ + return configured.endsWith('/') ? configured : configured + '/'; + }} + const markers = ['/lab/', '/notebooks/', '/tree/']; + for (const marker of markers) {{ + const index = window.location.pathname.indexOf(marker); + if (index >= 0) {{ + return window.location.pathname.slice(0, index + 1); + }} + }} + return '/'; + }} + + function token(config) {{ + return config.token || new URLSearchParams(window.location.search).get('token') || ''; + }} + + function cookie(name) {{ + const prefix = name + '='; + for (const part of document.cookie.split(';')) {{ + const trimmed = part.trim(); + if (trimmed.startsWith(prefix)) {{ + return decodeURIComponent(trimmed.slice(prefix.length)); + }} + }} + return ''; + }} + + function notebookPath() {{ + const decoded = decodeURIComponent(window.location.pathname); + const markers = ['/lab/tree/', '/notebooks/', '/tree/']; + for (const marker of markers) {{ + const index = decoded.indexOf(marker); + if (index >= 0) {{ + return decoded.slice(index + marker.length); + }} + }} + return ''; + }} + + async function kernelFromSessions(config) {{ + const url = new URL(baseUrl(config) + 'api/sessions', window.location.origin); + const authToken = token(config); + if (authToken) {{ + url.searchParams.set('token', authToken); + }} + const response = await fetch(url, {{credentials: 'same-origin'}}); + if (!response.ok) {{ + return ''; + }} + const sessions = await response.json(); + const path = notebookPath(); + const session = sessions.find((item) => item.path === path) || sessions[0]; + return session && session.kernel ? session.kernel.id : ''; + }} + + async function interruptKernel(config, resolvedKernelId) {{ + const url = new URL( + baseUrl(config) + 'api/kernels/' + resolvedKernelId + '/interrupt', + window.location.origin + ); + const authToken = token(config); + if (authToken) {{ + url.searchParams.set('token', authToken); + }} + const xsrfToken = cookie('_xsrf'); + const headers = {{}}; + if (xsrfToken) {{ + headers['X-XSRFToken'] = xsrfToken; + }} + const response = await fetch(url, {{ + method: 'POST', + credentials: 'same-origin', + headers: headers + }}); + return response.ok; + }} + + button.addEventListener('click', async function() {{ + button.disabled = true; + setStatus('Stopping...'); + const config = pageConfig(); + try {{ + const resolvedKernelId = kernelId || await kernelFromSessions(config); + if (!resolvedKernelId) {{ + throw new Error('Could not resolve the current kernel id.'); + }} + const interrupted = await interruptKernel(config, resolvedKernelId); + if (!interrupted) {{ + throw new Error('Jupyter Server rejected the interrupt request.'); + }} + setStatus('Interrupt sent...'); + }} catch (error) {{ + button.disabled = false; + setStatus('Use Kernel > Interrupt to stop this fit.'); + }} + }}); +}})(); +""" + + @staticmethod + def _current_kernel_id() -> str: + """Return the active ipykernel id when available.""" + try: + from IPython import get_ipython # type: ignore[import-not-found] # noqa: PLC0415 + except ImportError: # pragma: no cover - optional dependency + return '' + + shell = get_ipython() + kernel = getattr(shell, 'kernel', None) + kernel_id = getattr(kernel, 'kernel_id', None) + if kernel_id: + return str(kernel_id) + + try: + from ipykernel.connect import ( # type: ignore[import-not-found] # noqa: PLC0415 + get_connection_file, + ) + except ImportError: # pragma: no cover - optional dependency + return '' + + with suppress(Exception): + return NotebookFitStopControl._kernel_id_from_connection_file(get_connection_file()) + return '' + + @staticmethod + def _kernel_id_from_connection_file(connection_file: str) -> str: + """Extract the kernel id from an ipykernel connection file.""" + file_name = Path(connection_file).name + prefix = 'kernel-' + suffix = '.json' + if not file_name.startswith(prefix) or not file_name.endswith(suffix): + return '' + return file_name[len(prefix) : -len(suffix)] + + +def notebook_fit_stop_control( + *, + verbosity: VerbosityEnum, +) -> NotebookFitStopControl: + """Return a notebook stop-control context for fitting runs.""" + return NotebookFitStopControl(verbosity=verbosity) diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py index fcd209d08..873aac0ee 100644 --- a/src/easydiffraction/io/cif/serialize.py +++ b/src/easydiffraction/io/cif/serialize.py @@ -174,16 +174,18 @@ def category_item_to_cif(item: object) -> str: Expects ``item.parameters`` iterable of params with ``_cif_handler.names`` and ``value``. """ - lines: list[str] = [param_to_cif(p) for p in item.parameters] + parameters_hook = getattr(item, '_cif_parameters', None) + parameters = parameters_hook() if parameters_hook is not None else item.parameters + lines: list[str] = [param_to_cif(p) for p in parameters] return '\n'.join(lines) def _validate_loop_tags( - item: object, + parameters: list[GenericDescriptorBase], header_tags: list[str], ) -> None: """Log an error if any row tag disagrees with *header_tags*.""" - for col, p in enumerate(item.parameters): + for col, p in enumerate(parameters): tag = p._cif_handler.names[0] # type: ignore[attr-defined] if tag != header_tags[col]: log.error( @@ -197,6 +199,7 @@ def _validate_loop_tags( def _emit_loop_rows( items: list, row_fn: object, + row_parameters_fn: object, header_tags: list[str], max_display: int | None, ) -> list[str]: @@ -205,15 +208,15 @@ def _emit_loop_rows( if max_display is not None and len(items) > max_display: half = max_display // 2 for item in items[:half]: - _validate_loop_tags(item, header_tags) + _validate_loop_tags(row_parameters_fn(item), header_tags) lines.append(' '.join(row_fn(item))) lines.append('...') for item in items[-half:]: - _validate_loop_tags(item, header_tags) + _validate_loop_tags(row_parameters_fn(item), header_tags) lines.append(' '.join(row_fn(item))) else: for item in items: - _validate_loop_tags(item, header_tags) + _validate_loop_tags(row_parameters_fn(item), header_tags) lines.append(' '.join(row_fn(item))) return lines @@ -253,11 +256,18 @@ def category_collection_to_cif( if not len(collection): return '\n'.join(lines) + loop_parameters_hook = getattr(collection, '_cif_loop_parameters', None) + + def _loop_parameters(item: object) -> list[GenericDescriptorBase]: + if loop_parameters_hook is not None: + return list(loop_parameters_hook(item)) + return list(item.parameters) + # Header — use first item's CIF tag names as the canonical columns first_item = next(iter(collection.values())) lines.append('loop_') header_tags: list[str] = [] - for p in first_item.parameters: + for p in _loop_parameters(first_item): tags = p._cif_handler.names # type: ignore[attr-defined] header_tags.append(tags[0]) lines.append(tags[0]) @@ -270,10 +280,10 @@ def _row(item: object) -> list[str]: override = row_hook(item) if override is not None: return override - return [format_param_value(p) for p in item.parameters] + return [format_param_value(p) for p in _loop_parameters(item)] items = list(collection.values()) - lines.extend(_emit_loop_rows(items, _row, header_tags, max_display)) + lines.extend(_emit_loop_rows(items, _row, _loop_parameters, header_tags, max_display)) return '\n'.join(lines) diff --git a/src/easydiffraction/io/results_sidecar.py b/src/easydiffraction/io/results_sidecar.py index db7ed17cf..920bc947b 100644 --- a/src/easydiffraction/io/results_sidecar.py +++ b/src/easydiffraction/io/results_sidecar.py @@ -22,6 +22,12 @@ _DISTRIBUTION_CACHE_GROUP = '/distribution_cache' _PAIR_CACHE_GROUP = '/pair_cache' _PREDICTIVE_GROUP = '/predictive' +_CANONICAL_GROUPS = ( + 'posterior', + 'distribution_cache', + 'pair_cache', + 'predictive', +) _POSTERIOR_SAMPLE_NDIM = 3 @@ -54,9 +60,8 @@ def _delete_stale_sidecar(sidecar_path: Path) -> None: sidecar_path.unlink() -def warn_analysis_results_sidecar_overwrite(*, analysis_dir: Path) -> None: +def _warn_existing_sidecar_overwrite(sidecar_path: Path) -> None: """Warn when a new fit will overwrite existing sidecar arrays.""" - sidecar_path = _sidecar_path(analysis_dir=analysis_dir) if not sidecar_path.is_file() or sidecar_path.stat().st_size == 0: return @@ -66,6 +71,14 @@ def warn_analysis_results_sidecar_overwrite(*, analysis_dir: Path) -> None: ) +def prepare_analysis_results_sidecar_for_new_fit(*, analysis_dir: Path) -> None: + """Warn and remove the results sidecar before a fresh fit starts.""" + sidecar_path = _sidecar_path(analysis_dir=analysis_dir) + _warn_existing_sidecar_overwrite(sidecar_path) + if sidecar_path.is_file(): + sidecar_path.unlink() + + def _create_dataset(handle: object, path: str, data: np.ndarray) -> None: """Create or replace one dataset in an open HDF5 file.""" normalized_path = _normalized_hdf5_path(path) @@ -76,6 +89,22 @@ def _create_dataset(handle: object, path: str, data: np.ndarray) -> None: group.create_dataset(dataset_name, data=data) +def _delete_group_if_present(handle: object, group_name: str) -> None: + """ + Delete one top-level group from an open HDF5 file when present. + """ + if group_name in handle: + del handle[group_name] + + +def _delete_canonical_groups(handle: object) -> None: + """ + Delete EasyDiffraction-owned top-level groups before append writes. + """ + for group_name in _CANONICAL_GROUPS: + _delete_group_if_present(handle, group_name) + + def _read_dataset(handle: object, path: str) -> np.ndarray | None: """Read one dataset from an open HDF5 file when it exists.""" normalized_path = _normalized_hdf5_path(path) @@ -299,7 +328,8 @@ def write_analysis_results_sidecar( import h5py # noqa: PLC0415 analysis_dir.mkdir(parents=True, exist_ok=True) - with h5py.File(sidecar_path, 'w') as handle: + with h5py.File(sidecar_path, 'a') as handle: + _delete_canonical_groups(handle) wrote_any = _write_posterior_payload(handle, analysis) wrote_any = _write_distribution_caches(handle, analysis) or wrote_any wrote_any = _write_pair_caches(handle, analysis) or wrote_any diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index 5067e40d3..fb5983d8c 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from easydiffraction.analysis.fit_helpers.bayesian import posterior_predictive_cache_key from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum @@ -102,7 +103,7 @@ def _show_settings_used(self) -> None: if not rows: return - console.paragraph('Settings used') + console.print('⚙️ Settings used:') render_table( columns_headers=['Name', 'Value', 'Description'], columns_alignment=['left', 'right', 'left'], @@ -219,8 +220,8 @@ def _predictive_needs_processing_indicator( return True cache_keys = [ - plotter._posterior_predictive_key(expt_name, x_axis_name, include_draws=True), - plotter._posterior_predictive_key(expt_name, x_axis_name, include_draws=False), + posterior_predictive_cache_key(expt_name, x_axis_name, include_draws=True), + posterior_predictive_cache_key(expt_name, x_axis_name, include_draws=False), expt_name, ] for cache_key in cache_keys: diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index a9172be35..3f64ab20b 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -30,6 +30,7 @@ from easydiffraction.utils.environment import resolve_artifact_path from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log +from easydiffraction.utils.utils import display_path if TYPE_CHECKING: from collections.abc import Callable @@ -463,8 +464,7 @@ def save(self) -> None: log.error('Project path not specified. Use save_as() to define the path first.') return - console.paragraph(f"Saving project 📦 '{self.name}' to") - console.print(self.info.path.resolve()) + console.paragraph(f"Saving project 📦 '{self.name}' to '{display_path(self.info.path)}'") # Apply constraints so dependent parameters are flagged # before serialization (user-constrained params are written diff --git a/src/easydiffraction/utils/logging.py b/src/easydiffraction/utils/logging.py index 3c4da8ee5..a185205fb 100644 --- a/src/easydiffraction/utils/logging.py +++ b/src/easydiffraction/utils/logging.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: # pragma: no cover from types import TracebackType +import html import re import sys from pathlib import Path @@ -641,6 +642,36 @@ def critical(cls, *messages: str, exc_type: type[BaseException] = RuntimeError) # ====================================================================== +_RICH_RED_MARKUP_PATTERN = re.compile(r'\[red\](.*?)\[/red\]') +_RICH_DIM_MARKUP_PATTERN = re.compile(r'\[/?dim\]') + + +def _rich_markup_to_inline_html(markup: str) -> str: + """ + Translate a narrow subset of Rich markup to inline HTML. + + Handles ``[red]…[/red]`` (→ a red ````) and silently strips + ``[dim]`` / ``[/dim]`` tags (the surrounding container already + conveys dimness via CSS opacity). Other Rich markup passes through + unescaped — callers must only emit markup from this allow-list when + calling ``ConsolePrinter.small`` in Jupyter. + + Parameters + ---------- + markup : str + Rich markup string. + + Returns + ------- + str + HTML-escaped string with the allow-listed Rich tags translated + to inline ```` styles. + """ + escaped = html.escape(markup) + without_dim = _RICH_DIM_MARKUP_PATTERN.sub('', escaped) + return _RICH_RED_MARKUP_PATTERN.sub(r'\1', without_dim) + + class ConsolePrinter: """Printer utility for the shared console with left padding.""" @@ -706,6 +737,48 @@ def section(cls, title: str) -> None: formatted = f'\n{formatted}' cls._console.print(formatted) + @classmethod + def small(cls, *lines: str) -> None: + """ + Print one or more lines as dim, smaller supplementary text. + + Intended for table footnote glossaries and inline warning notes + that should read as subordinate to the table or block they sit + beneath. In Jupyter the lines render inside a single + ````-style HTML element so the font size matches + Jupyter's ``.dataframe`` table-cell text. In a terminal the + lines render with Rich's ``dim`` style. Rich ``[red]…[/red]`` + markup inside ``lines`` is preserved in both renderers. + + Parameters + ---------- + *lines : str + Pre-formatted display lines. Each may contain Rich + ``[red]…[/red]`` markup; other Rich markup is rendered in + the terminal and stripped in the HTML output. + """ + if not lines: + return + if in_jupyter(): + try: + from IPython.display import HTML # noqa: PLC0415 + from IPython.display import display # noqa: PLC0415 + + body = '
'.join(_rich_markup_to_inline_html(line) for line in lines) + except ImportError: # pragma: no cover + pass + else: + display( + HTML( + '
' + f'{body}
' + ) + ) + return + for line in lines: + cls._console.print(f'[dim]{line}[/dim]') + @classmethod def chapter(cls, title: str) -> None: """Format a chapter header in bold magenta, uppercase.""" diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py index e07b15755..2af1f53b1 100644 --- a/src/easydiffraction/utils/utils.py +++ b/src/easydiffraction/utils/utils.py @@ -28,12 +28,108 @@ pooch.get_logger().setLevel('WARNING') # Suppress pooch info messages + +def display_path(path: pathlib.Path | str) -> str: + """ + Format a filesystem path for user-facing display. + + Returns the path relative to the current working directory so + messages stay compact and avoid forced line breaks. Paths outside + the cwd subtree use ``..`` segments to walk up to a common ancestor + (e.g. ``../sibling/data.cif``) rather than falling back to an + absolute path. The absolute path is only used when no relative form + is possible — on Windows that happens when the path is on a + different drive from the cwd. + + Parameters + ---------- + path : pathlib.Path | str + Filesystem path to format. + + Returns + ------- + str + Display string suitable for inline use in console messages. + """ + resolved = pathlib.Path(path).resolve() + cwd = pathlib.Path.cwd().resolve() + try: + return str(resolved.relative_to(cwd, walk_up=True)) + except ValueError: + return str(resolved) + + +def print_metrics_table(rows: list[list[str]]) -> None: + """ + Render a two-column ``Metric | Value`` table. + + Used for fit-results, settings, and similar summary blocks where + each row is one labelled scalar. Skips rendering entirely when + ``rows`` is empty. + + Parameters + ---------- + rows : list[list[str]] + Each inner list is ``[label, value_string]``. + """ + if not rows: + return + render_table( + columns_headers=['Metric', 'Value'], + columns_alignment=['left', 'right'], + columns_data=rows, + ) + + +def print_table_footnote(entries: list[tuple[str, str]]) -> None: + """ + Print a glossary block below a fit-results-style table. + + Each entry renders as a left-aligned ``• header = description`` + bullet line. The block uses :meth:`ConsolePrinter.small` so it shows + as dim, smaller supplementary text — in Jupyter the font size + matches the table-cell text. + + Parameters + ---------- + entries : list[tuple[str, str]] + Each tuple is ``(column header, one-line description)``. + """ + if not entries: + return + width = max(len(name) for name, _ in entries) + 4 + lines = [f' • {name:<{width}} = {description}' for name, description in entries] + console.small(*lines) + + +def format_bulleted_warning(header: str, items: list[str]) -> str: + """ + Format a warning as a header followed by indented bullets. + + Parameters + ---------- + header : str + First warning line. Use a trailing colon when bullets follow. + items : list[str] + Bullet line bodies. + + Returns + ------- + str + Multiline warning text. + """ + if not items: + return header + bullet_lines = [f'• {item}' for item in items] + return '\n'.join([header, *bullet_lines]) + + _DATA_REPO = 'easyscience/diffraction' _DATA_ROOT = 'data' # commit SHA preferred -_DATA_INDEX_REF = 'dbe92a87e0106c4742eee0ff9a8e32bdb8b483cb' +_DATA_INDEX_REF = '83657ee120fc6a30fda231649692930eaa038758' # macOS: sha256sum index.json -_DATA_INDEX_HASH = 'sha256:9e7bbaf2cb650f4126572e85157c63bc76f201408856fe4af566bee55dcdfbb4' +_DATA_INDEX_HASH = 'sha256:e7685d7c81c3b3559a7f630178f4d1b7f441fb1ed14388c10ab9f6aeb93927a7' def _build_data_url(path: str) -> str: @@ -241,8 +337,8 @@ def download_data( existing_project_dir = _existing_project_dir(extraction_dir) if existing_project_dir is not None: console.print( - f"✅ Data #{id} already extracted at '{existing_project_dir}'. " - 'Keeping existing project.' + f"✅ Data #{id} already extracted at '{display_path(existing_project_dir)}'. " + 'Keeping existing.' ) return str(existing_project_dir) @@ -250,14 +346,16 @@ def download_data( if is_project_archive and not overwrite: project_dir = extract_project_from_zip(file_path, destination=extraction_dir) file_path.unlink() - console.print(f"✅ Data #{id} extracted to '{project_dir}'") + console.print(f"✅ Data #{id} extracted to '{display_path(project_dir)}'") return str(project_dir) if not overwrite: console.print( - f"✅ Data #{id} already present at '{file_path}'. Keeping existing file." + f"✅ Data #{id} already present at '{display_path(file_path)}'. Keeping existing." ) return str(file_path) - log.debug(f"Data #{id} already present at '{file_path}', but will be overwritten.") + log.debug( + f"Data #{id} already present at '{display_path(file_path)}', but will be overwritten." + ) file_path.unlink() known_hash = _normalize_known_hash(record.get('hash')) @@ -276,10 +374,10 @@ def download_data( if is_project_archive: project_dir = extract_project_from_zip(file_path, destination=extraction_dir) file_path.unlink() - console.print(f"✅ Data #{id} downloaded and extracted to\n'{project_dir}'") + console.print(f"✅ Data #{id} downloaded and extracted to '{display_path(project_dir)}'") return str(project_dir) - console.print(f"✅ Data #{id} downloaded to:\n'{file_path}'") + console.print(f"✅ Data #{id} downloaded to '{display_path(file_path)}'") return str(file_path) @@ -504,7 +602,9 @@ def download_tutorial( id : int | str Numeric tutorial id (e.g. 1). destination : str, default='tutorials' - Directory to save the file into (created if missing). + Directory to save the file into (created if missing). Relative + destinations are resolved against the configured artifact root + when ``EASYDIFFRACTION_ARTIFACT_ROOT`` is set. overwrite : bool, default=False Whether to overwrite the file if it already exists. @@ -535,7 +635,7 @@ def download_tutorial( fname = f'ed-{id}.ipynb' - dest_path = pathlib.Path(destination) + dest_path = resolve_artifact_path(destination) dest_path.mkdir(parents=True, exist_ok=True) file_path = dest_path / fname @@ -550,17 +650,21 @@ def download_tutorial( if file_path.exists(): if not overwrite: console.print( - f"✅ Tutorial #{id} already present at '{file_path}'. Keeping existing file." + f"✅ Tutorial #{id} already present at '{display_path(file_path)}'. " + 'Keeping existing.' ) return str(file_path) - log.debug(f"Tutorial #{id} already present at '{file_path}', but will be overwritten.") + log.debug( + f"Tutorial #{id} already present at '{display_path(file_path)}', " + 'but will be overwritten.' + ) file_path.unlink() # Download the notebook with _safe_urlopen(url) as resp: file_path.write_bytes(resp.read()) - console.print(f"✅ Tutorial #{id} downloaded to:\n'{file_path}'") + console.print(f"✅ Tutorial #{id} downloaded to '{display_path(file_path)}'") return str(file_path) @@ -577,7 +681,9 @@ def download_all_tutorials( Parameters ---------- destination : str, default='tutorials' - Directory to save the files into (created if missing). + Directory to save the files into (created if missing). Relative + destinations are resolved against the configured artifact root + when ``EASYDIFFRACTION_ARTIFACT_ROOT`` is set. overwrite : bool, default=False Whether to overwrite files if they already exist. @@ -606,7 +712,11 @@ def download_all_tutorials( except (OSError, ValueError) as e: log.warning(f'Failed to download tutorial #{tutorial_id}: {e}') - console.print(f'✅ Downloaded {len(downloaded_paths)} tutorials to "{destination}/"') + resolved_destination = resolve_artifact_path(destination) + console.print( + f'✅ Downloaded {len(downloaded_paths)} tutorials to ' + f"'{display_path(resolved_destination)}'" + ) return downloaded_paths diff --git a/tests/integration/fitting/test_bayesian_dream.py b/tests/integration/fitting/test_bayesian_dream.py index b07b59f05..31200246b 100644 --- a/tests/integration/fitting/test_bayesian_dream.py +++ b/tests/integration/fitting/test_bayesian_dream.py @@ -11,6 +11,7 @@ from easydiffraction import Project from easydiffraction import StructureFactory from easydiffraction import download_data +from easydiffraction.analysis.fitting import FitterFitOptions TEMP_DIR = tempfile.gettempdir() @@ -107,8 +108,7 @@ def _run_single_fit(project: Project, *, random_seed: int | None = None) -> None verb, structures, experiments, - use_physical_limits=False, - random_seed=random_seed, + fit_options=FitterFitOptions(random_seed=random_seed), ) diff --git a/tests/integration/fitting/test_bayesian_helper_support.py b/tests/integration/fitting/test_bayesian_helper_support.py index b67d5db02..c8028d3b7 100644 --- a/tests/integration/fitting/test_bayesian_helper_support.py +++ b/tests/integration/fitting/test_bayesian_helper_support.py @@ -33,7 +33,7 @@ def __init__(self, unique_name: str, start: float, value: float, uncertainty: fl self.units = 'arb' -def test_posterior_samples_flatten_and_to_arviz(): +def test_posterior_samples_flatten(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples posterior_samples = PosteriorSamples( @@ -49,17 +49,13 @@ def test_posterior_samples_flatten_and_to_arviz(): ) flattened = posterior_samples.flattened() - inference_data = posterior_samples.to_arviz() assert flattened.shape == (4, 2) np.testing.assert_allclose(flattened[:, 0], np.array([1.0, 2.0, 3.0, 4.0])) np.testing.assert_allclose(flattened[:, 1], np.array([10.0, 20.0, 30.0, 40.0])) - assert set(inference_data.posterior.data_vars) == {'a', 'b'} - assert inference_data.posterior['a'].shape == (2, 2) - assert inference_data.sample_stats['lp'].shape == (2, 2) -def test_posterior_samples_to_arviz_validates_shapes(): +def test_posterior_samples_validate_shapes_rejects_wrong_ndim(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples posterior_samples = PosteriorSamples( @@ -71,10 +67,10 @@ def test_posterior_samples_to_arviz_validates_shapes(): ValueError, match=r'Posterior sample array must have shape \(n_draws, n_chains, n_parameters\)\.', ): - posterior_samples.to_arviz() + posterior_samples.validate_shapes() -def test_posterior_samples_to_arviz_validates_name_and_log_posterior_lengths(): +def test_posterior_samples_validate_shapes_rejects_name_and_log_posterior_mismatches(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples wrong_names = PosteriorSamples( @@ -85,7 +81,7 @@ def test_posterior_samples_to_arviz_validates_name_and_log_posterior_lengths(): ValueError, match=r'Posterior sample array does not match the parameter name list length\.', ): - wrong_names.to_arviz() + wrong_names.validate_shapes() wrong_log_posterior = PosteriorSamples( parameter_names=['a'], @@ -96,7 +92,7 @@ def test_posterior_samples_to_arviz_validates_name_and_log_posterior_lengths(): ValueError, match=r'Log-posterior array must match the first two posterior sample axes\.', ): - wrong_log_posterior.to_arviz() + wrong_log_posterior.validate_shapes() def test_compute_convergence_diagnostics_treats_non_finite_values_as_not_converged( @@ -110,17 +106,13 @@ def test_compute_convergence_diagnostics_treats_non_finite_values_as_not_converg parameter_samples=np.ones((4, 2, 1), dtype=float), ) - fake_dataset = type('FakeDataset', (), {'data_vars': {'a': np.array([np.nan], dtype=float)}}) - monkeypatch.setattr( - 'easydiffraction.analysis.fit_helpers.bayesian.az.rhat', - lambda inference_data: fake_dataset, + 'easydiffraction.analysis.fit_helpers.bayesian.compute_r_hat', + lambda _samples: float('nan'), ) monkeypatch.setattr( - 'easydiffraction.analysis.fit_helpers.bayesian.az.ess', - lambda inference_data, method='bulk': type( - 'FakeDataset', (), {'data_vars': {'a': np.array([4000.0], dtype=float)}} - ), + 'easydiffraction.analysis.fit_helpers.bayesian.compute_ess_bulk', + lambda _samples: 4000.0, ) diagnostics = compute_convergence_diagnostics(posterior_samples) @@ -217,61 +209,47 @@ def test_standard_deviations_from_summaries_returns_float_array(): def test_bayesian_format_helpers_cover_edge_cases(): + from easydiffraction.analysis.fit_helpers.bayesian import _bayesian_overall_status from easydiffraction.analysis.fit_helpers.bayesian import _calculate_fit_quality_metrics - from easydiffraction.analysis.fit_helpers.bayesian import _dataset_to_scalar_dict - from easydiffraction.analysis.fit_helpers.bayesian import _format_bayesian_overall_status - from easydiffraction.analysis.fit_helpers.bayesian import _format_convergence_summary - from easydiffraction.analysis.fit_helpers.bayesian import _format_point_estimate_name - from easydiffraction.analysis.fit_helpers.bayesian import _format_sampler_settings from easydiffraction.analysis.fit_helpers.bayesian import _maybe_scalar - dataset = type( - 'FakeDataset', - (), - {'data_vars': {'a': np.array([np.nan], dtype=float), 'b': np.array([3.0], dtype=float)}}, - ) - assert _maybe_scalar(None) is None assert _maybe_scalar(float('inf')) is None assert _maybe_scalar(3.0) == pytest.approx(3.0) - assert _dataset_to_scalar_dict(dataset) == {'a': None, 'b': 3.0} - assert _format_sampler_settings({}) is None + + # Two-state overall-status helper: 'success' only when sampler + # completed AND convergence passed. assert ( - _format_sampler_settings({'steps': 10, 'burn': 2, 'samples': 40}) - == 'steps=10, burn=2, samples=40' + _bayesian_overall_status( + success=False, + sampler_completed=False, + convergence_diagnostics={}, + ) + == 'failed' ) - assert _format_point_estimate_name('map') == 'Best posterior sample' - assert _format_point_estimate_name('best_sample') == 'Best posterior sample' - assert _format_bayesian_overall_status( - success=False, - sampler_completed=False, - convergence_diagnostics={}, - ) == ('❌', 'failed') - assert _format_bayesian_overall_status( - success=True, - sampler_completed=False, - convergence_diagnostics={'converged': False}, - ) == ('⚠️', 'completed with warnings') - assert _format_bayesian_overall_status( - success=True, - sampler_completed=True, - convergence_diagnostics={'converged': True}, - ) == ('✅', 'completed') - assert _format_bayesian_overall_status( - success=True, - sampler_completed=False, - convergence_diagnostics={}, - ) == ('✅', 'posterior available') - assert _format_convergence_summary({}) is None - assert _format_convergence_summary({ - 'converged': False, - 'max_r_hat': 1.02, - 'min_ess_bulk': 200.0, - 'n_draws': 30, - 'n_chains': 8, - }) == ( - 'status=[red]failed[/red], max_r_hat=[red]1.020[/red], ' - 'min_ess_bulk=[red]200.0[/red], draws=30, chains=8' + assert ( + _bayesian_overall_status( + success=True, + sampler_completed=False, + convergence_diagnostics={'converged': False}, + ) + == 'failed' + ) + assert ( + _bayesian_overall_status( + success=True, + sampler_completed=True, + convergence_diagnostics={'converged': True}, + ) + == 'success' + ) + assert ( + _bayesian_overall_status( + success=True, + sampler_completed=False, + convergence_diagnostics={}, + ) + == 'failed' ) metrics = _calculate_fit_quality_metrics( @@ -339,22 +317,24 @@ def test_bayesian_fit_results_display_results_prints_sampler_and_convergence(cap out = _unstyled_output(capsys.readouterr().out) assert 'Bayesian fit results' in out - assert 'Overall status: completed with warnings' in out - assert 'Sampler status: DREAM sampling completed' in out - assert 'Sampler: dream' in out - assert 'Sampler completed: yes' in out - assert 'steps=200' in out - assert 'init=lhs' in out - assert 'random_seed=1313900679' not in out - assert 'status=failed' in out - assert 'max_r_hat=1.107' in out - assert 'min_ess_bulk=125.9' in out - assert 'Posterior parameter summaries:' in out + assert 'Overall status' in out + assert 'failed' in out # convergence failed → overall failed + assert 'DREAM sampling completed' in out # engine message + assert 'Sampler' in out + assert 'Convergence status' in out + assert 'Max r-hat' in out + assert '1.107' in out + assert 'Min ess bulk' in out + assert '125.9' in out + assert 'Posterior distribution:' in out assert 'Success: True' not in out + assert 'Sampler completed' not in out # dropped — redundant with Overall status + assert 'Sampler settings' not in out # dropped — covered by Settings used table + assert 'Committed point estimate' not in out # dropped — covered by footnote assert 'datablock' in out assert 'category' in out assert 'entry' in out - assert '95% interval' in out + assert '95% CI' in out assert '68% interval' not in out assert 'std' not in out @@ -424,8 +404,8 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'start', - 'best posterior sample', - 'uncertainty', + 'value', + 's.u.', 'change', ] assert captured['columns_alignment'] == [ @@ -491,7 +471,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'median', - '95% interval', + '95% CI', 'r-hat', 'ess bulk', ] @@ -605,14 +585,16 @@ def test_fitresults_display_results_prints_and_table(capsys): ) out = _unstyled_output(capsys.readouterr().out) - assert 'Fit results' in out - assert 'Success: True' in out + assert 'Least-squares fit results:' in out + assert 'Overall status' in out + assert 'success' in out assert 'reduced χ²' in out - assert 'R-factor (Rf)' in out - assert 'R-factor squared (Rf²)' in out - assert 'Weighted R-factor (wR)' in out - assert 'Bragg R-factor (BR)' in out - assert 'Fitted parameters:' in out + assert 'R-factor (Rf' in out + assert 'R-factor squared (Rf²' in out + assert 'Weighted R-factor (wR' in out + assert 'Bragg R-factor (BR' in out + assert 'Refined parameters:' in out + assert 'Success: True' not in out # replaced by Overall status row assert any(char in out for char in ('╒', '┌', '+', '─')) @@ -640,8 +622,8 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'start', - 'fitted', - 'uncertainty', + 'value', + 's.u.', 'change', ] assert captured['columns_alignment'] == [ diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py index 91ec0e572..b120e1023 100644 --- a/tests/integration/fitting/test_bayesian_tracker_and_base.py +++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py @@ -413,6 +413,8 @@ def _check_success(self, raw_result): def test_minimizer_base_applies_physical_limits_and_warns(monkeypatch): + from easydiffraction.analysis.minimizers.base import MinimizerFitOptions + from easydiffraction.analysis.minimizers.base import MinimizerBase warnings: list[str] = [] @@ -470,7 +472,7 @@ def _compute_residuals( result = minimizer.fit( parameters=[parameter], objective_function=objective, - use_physical_limits=True, + options=MinimizerFitOptions(use_physical_limits=True), ) assert result.success is True @@ -483,6 +485,7 @@ def _compute_residuals( def test_minimizer_base_rejects_random_seed_when_not_supported(): from easydiffraction.analysis.minimizers.base import MinimizerBase + from easydiffraction.analysis.minimizers.base import MinimizerFitOptions class Minimizer(MinimizerBase): def _prepare_solver_args(self, parameters): @@ -508,4 +511,8 @@ def _compute_residuals( ValueError, match=r"Minimizer 'dummy' does not support random_seed\.", ): - minimizer.fit(parameters=[], objective_function=lambda _: np.array([0.0]), random_seed=7) + minimizer.fit( + parameters=[], + objective_function=lambda _: np.array([0.0]), + options=MinimizerFitOptions(random_seed=7), + ) diff --git a/tests/integration/fitting/test_emcee.py b/tests/integration/fitting/test_emcee.py new file mode 100644 index 000000000..3f33739d8 --- /dev/null +++ b/tests/integration/fitting/test_emcee.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Integration checks for emcee Bayesian sampling.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pytest + +from easydiffraction.utils.enums import VerbosityEnum + + +@dataclass +class ToyParameter: + """Minimal parameter object accepted by the Bayesian engines.""" + + unique_name: str + value: float + fit_min: float + fit_max: float + uncertainty: float | None = None + + @property + def name(self) -> str: + """Return the display name used in posterior summaries.""" + return self.unique_name + + @property + def _minimizer_uid(self) -> str: + """Return the BUMPS parameter identifier.""" + return self.unique_name + + def _set_value_from_minimizer(self, value: float) -> None: + """Store a value committed by the minimizer.""" + self.value = value + + def _physical_lower_bound(self) -> float: + """Return the lower physical limit for warning checks.""" + return -np.inf + + def _physical_upper_bound(self) -> float: + """Return the upper physical limit for warning checks.""" + return np.inf + + +def _toy_parameters() -> list[ToyParameter]: + return [ + ToyParameter(unique_name='x', value=0.0, fit_min=-4.0, fit_max=4.0), + ToyParameter(unique_name='y', value=0.0, fit_min=-4.0, fit_max=4.0), + ] + + +def _array_residuals(values: np.ndarray) -> np.ndarray: + target = np.asarray([1.2, -0.7], dtype=float) + sigma = np.asarray([0.25, 0.35], dtype=float) + return (np.asarray(values, dtype=float) - target) / sigma + + +def _mapping_residuals(values: dict[str, object]) -> np.ndarray: + return _array_residuals(np.asarray([values['x'], values['y']], dtype=float)) + + +def _posterior_medians(results: object) -> np.ndarray: + return np.asarray( + [summary.median for summary in results.posterior_parameter_summaries], + dtype=float, + ) + + +@pytest.mark.parametrize('proposal_moves', ['de']) +def test_emcee_resume_matches_small_dream_posterior(tmp_path, proposal_moves): + from easydiffraction.analysis.minimizers.base import MinimizerFitOptions + from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + dream = BumpsDreamMinimizer() + dream.steps = 80 + dream.burn = 20 + dream.thin = 1 + dream.pop = 4 + dream.parallel = 1 + dream_results = dream.fit( + _toy_parameters(), + _array_residuals, + verbosity=VerbosityEnum.SILENT, + options=MinimizerFitOptions(random_seed=123), + ) + + emcee = EmceeMinimizer() + emcee.nsteps = 80 + emcee.nburn = 20 + emcee.thin = 1 + emcee.nwalkers = 16 + emcee.parallel_workers = 1 + emcee.proposal_moves = proposal_moves + emcee._sidecar_path = tmp_path / 'analysis' / 'results.h5' + emcee_results = emcee.fit( + _toy_parameters(), + _mapping_residuals, + verbosity=VerbosityEnum.SILENT, + options=MinimizerFitOptions(random_seed=123), + ) + resumed_results = emcee.fit( + _toy_parameters(), + _mapping_residuals, + verbosity=VerbosityEnum.SILENT, + options=MinimizerFitOptions(random_seed=123, resume=True, extra_steps=20), + ) + + assert dream_results.success is True + assert emcee_results.success is True + assert resumed_results.success is True + assert resumed_results.posterior_samples is not None + assert resumed_results.posterior_samples.parameter_samples.shape[1:] == (16, 2) + assert resumed_results.sampler_settings['total_steps'] == 121 + np.testing.assert_allclose( + _posterior_medians(resumed_results), + _posterior_medians(dream_results), + atol=0.35, + ) diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py index 40c1a2657..f636170dd 100644 --- a/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_bayesian.py @@ -18,6 +18,7 @@ def test_bayesian_fit_result_defaults_unknown_outputs_to_none(): assert fit_result.sampler_completed.value is None assert fit_result.credible_interval_inner.value == 0.68 assert fit_result.credible_interval_outer.value == 0.95 + assert fit_result.resolved_random_seed.value is None assert fit_result.acceptance_rate_mean.value is None assert fit_result.gelman_rubin_max.value is None assert fit_result.effective_sample_size_min.value is None @@ -34,6 +35,7 @@ def test_bayesian_fit_result_round_trips_cif_outputs(): fit_result._set_sampler_completed(value=True) fit_result._set_credible_interval_inner(0.5) fit_result._set_credible_interval_outer(0.9) + fit_result._set_resolved_random_seed(12345) fit_result._set_acceptance_rate_mean(0.42) fit_result._set_gelman_rubin_max(1.01) fit_result._set_effective_sample_size_min(80) @@ -46,7 +48,47 @@ def test_bayesian_fit_result_round_trips_cif_outputs(): assert restored.sampler_completed.value is True assert restored.credible_interval_inner.value == 0.5 assert restored.credible_interval_outer.value == 0.9 + assert restored.resolved_random_seed.value == 12345 assert restored.acceptance_rate_mean.value == 0.42 assert restored.gelman_rubin_max.value == 1.01 assert restored.effective_sample_size_min.value == 80 assert restored.best_log_posterior.value == -12.5 + + +def test_bayesian_fit_result_omits_optional_unknown_outputs(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + + cif_text = BayesianFitResult().as_cif + + assert '_fit_result.acceptance_rate_mean' not in cif_text + assert '_fit_result.resolved_random_seed' not in cif_text + + +def test_bayesian_fit_result_omits_redundant_iterations(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + + fit_result = BayesianFitResult() + fit_result._set_iterations(100) + + cif_text = fit_result.as_cif + + assert '_fit_result.iterations' not in cif_text + + +def test_bayesian_fit_result_keeps_optional_outputs_when_populated(): + from easydiffraction.analysis.categories.fit_result.bayesian import ( + BayesianFitResult, + ) + + fit_result = BayesianFitResult() + fit_result._set_resolved_random_seed(12345) + fit_result._set_acceptance_rate_mean(0.42) + + cif_text = fit_result.as_cif + + assert '_fit_result.resolved_random_seed 12345' in cif_text + assert '_fit_result.acceptance_rate_mean 0.42' in cif_text diff --git a/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py b/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py index bce6e3670..d9e1c5e85 100644 --- a/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py +++ b/tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py @@ -53,3 +53,33 @@ def test_least_squares_fit_result_round_trips_cif_outputs(): assert restored.covariance_available.value is True assert restored.correlation_available.value is False assert restored.exit_reason.value == 'converged' + + +def test_least_squares_fit_result_omits_duplicate_exit_reason(): + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + + fit_result = LeastSquaresFitResult() + fit_result._set_message('Fit succeeded.') + fit_result._set_exit_reason('Fit succeeded.') + + cif_text = fit_result.as_cif + + assert '_fit_result.message "Fit succeeded."' in cif_text + assert '_fit_result.exit_reason' not in cif_text + + +def test_least_squares_fit_result_keeps_distinct_exit_reason(): + from easydiffraction.analysis.categories.fit_result.lsq import ( + LeastSquaresFitResult, + ) + + fit_result = LeastSquaresFitResult() + fit_result._set_message('Fit failed.') + fit_result._set_exit_reason('maximum number of evaluations reached') + + cif_text = fit_result.as_cif + + assert '_fit_result.message "Fit failed."' in cif_text + assert '_fit_result.exit_reason "maximum number of evaluations reached"' in cif_text diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py index 576ac976a..bfffd94b2 100644 --- a/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_bayesian_base.py @@ -43,6 +43,29 @@ def test_bayesian_minimizer_rejects_unsupported_initialization_method(): minimizer.initialization_method = 'ball' +def test_bayesian_minimizer_keeps_unset_random_seed_in_cif(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + + cif_text = BumpsDreamMinimizer().as_cif + + assert '_minimizer.random_seed ?' in cif_text + + +def test_bayesian_minimizer_keeps_configured_random_seed_in_cif(): + from easydiffraction.analysis.categories.minimizer.bumps_dream import ( + BumpsDreamMinimizer, + ) + + minimizer = BumpsDreamMinimizer() + minimizer.random_seed = 123 + + cif_text = minimizer.as_cif + + assert '_minimizer.random_seed 123' in cif_text + + def test_bayesian_minimizer_reads_cif_unknown_values_as_defaults(): from easydiffraction.analysis.categories.minimizer.bumps_dream import ( BumpsDreamMinimizer, diff --git a/tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py b/tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py new file mode 100644 index 000000000..9f4e904b0 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/minimizer/test_emcee.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the emcee minimizer category.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class _ExperimentCollection: + @property + def names(self) -> list[str]: + return [] + + +def _make_project() -> object: + return SimpleNamespace( + experiments=_ExperimentCollection(), + structures=object(), + info=SimpleNamespace(path=None), + _varname='proj', + ) + + +def test_emcee_minimizer_category_defaults_to_max_parallel_workers(): + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_PARALLEL_WORKERS, + ) + from easydiffraction.analysis.categories.minimizer.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + + assert DEFAULT_PARALLEL_WORKERS == 0 + assert minimizer.parallel_workers.value == 0 + assert minimizer._native_kwargs()['parallel_workers'] == 0 + + +def test_emcee_minimizer_category_defaults_to_de_without_thinning(): + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_PROPOSAL_MOVES, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_THINNING_INTERVAL, + ) + from easydiffraction.analysis.categories.minimizer.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + + assert DEFAULT_PROPOSAL_MOVES == 'de' + assert DEFAULT_THINNING_INTERVAL == 1 + assert minimizer.proposal_moves.value == 'de' + assert minimizer.thinning_interval.value == 1 + assert minimizer._native_kwargs()['proposal_moves'] == 'de' + assert minimizer._native_kwargs()['thin'] == 1 + + +def test_emcee_minimizer_category_maps_native_kwargs(): + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_BURN_IN_STEPS, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_INITIALIZATION_METHOD, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_PARALLEL_WORKERS, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_POPULATION_SIZE, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_PROPOSAL_MOVES, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_SAMPLING_STEPS, + ) + from easydiffraction.analysis.categories.minimizer.emcee import ( + DEFAULT_THINNING_INTERVAL, + ) + from easydiffraction.analysis.categories.minimizer.emcee import EmceeMinimizer + + native_kwargs = EmceeMinimizer()._native_kwargs() + + assert native_kwargs == { + 'nsteps': DEFAULT_SAMPLING_STEPS, + 'nburn': DEFAULT_BURN_IN_STEPS, + 'thin': DEFAULT_THINNING_INTERVAL, + 'nwalkers': DEFAULT_POPULATION_SIZE, + 'parallel_workers': DEFAULT_PARALLEL_WORKERS, + 'initialization_method': DEFAULT_INITIALIZATION_METHOD.value, + 'random_seed': None, + 'proposal_moves': DEFAULT_PROPOSAL_MOVES, + } + assert 'steps' not in native_kwargs + assert 'burn' not in native_kwargs + assert 'pop' not in native_kwargs + assert 'init' not in native_kwargs + + +def test_emcee_minimizer_swap_pairs_bayesian_fit_result(): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult + + analysis = Analysis(project=_make_project()) + + analysis.minimizer.type = 'emcee' + + assert isinstance(analysis.fit_result, BayesianFitResult) + assert analysis.fit_result._parent is analysis + + +def test_emcee_resume_parameter_set_mismatch_raises_before_sampler(): + import pytest + + from easydiffraction.analysis.fitting import Fitter + from easydiffraction.analysis.fitting import FitterFitOptions + + class Structures: + def __init__(self) -> None: + self.free_parameters = [SimpleNamespace(unique_name='current.param')] + + def __iter__(self) -> Iterator[object]: + return iter([self]) + + def _update_categories(self) -> None: + pass + + analysis = SimpleNamespace( + fit_parameters=[ + SimpleNamespace( + param_unique_name=SimpleNamespace(value='saved.param'), + ), + ], + ) + + with pytest.raises(ValueError, match='Resume parameter set differs'): + Fitter('emcee').fit( + structures=Structures(), + experiments=[], + analysis=analysis, + options=FitterFitOptions(resume=True), + ) diff --git a/tests/unit/easydiffraction/analysis/categories/test_fit_parameters.py b/tests/unit/easydiffraction/analysis/categories/test_fit_parameters.py index e3d0cedde..63b4cfa85 100644 --- a/tests/unit/easydiffraction/analysis/categories/test_fit_parameters.py +++ b/tests/unit/easydiffraction/analysis/categories/test_fit_parameters.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause """Tests for analysis/categories/fit_parameters/.""" +from types import SimpleNamespace + def test_fit_parameters_factory_create(): from easydiffraction.analysis.categories.fit_parameters.default import FitParameters @@ -11,3 +13,73 @@ def test_fit_parameters_factory_create(): assert FitParametersFactory.default_tag() == 'default' assert isinstance(collection, FitParameters) + + +def _fit_parameters_with_parent_result_kind(result_kind: str): + from easydiffraction.analysis.categories.fit_parameters.default import FitParameters + + collection = FitParameters() + collection.create( + param_unique_name='cosio.cell.length_a', + fit_min=-1.0, + fit_max=1.0, + start_value=10.3, + start_uncertainty=None, + ) + collection._parent = SimpleNamespace( + fit_result=SimpleNamespace( + result_kind=SimpleNamespace(value=result_kind), + ), + ) + return collection + + +def test_fit_parameters_cif_omits_posterior_columns_for_deterministic_result(): + from easydiffraction.analysis.enums import FitResultKindEnum + + collection = _fit_parameters_with_parent_result_kind(FitResultKindEnum.DETERMINISTIC.value) + + cif_text = collection.as_cif + + assert '_fit_parameter.start_value' in cif_text + assert '_fit_parameter.fit_bounds_uncertainty_multiplier' not in cif_text + assert '_fit_parameter.posterior_median' not in cif_text + assert '_fit_parameter.posterior_effective_sample_size_bulk' not in cif_text + + +def test_fit_parameters_cif_keeps_uncertainty_multiplier_when_populated(): + from easydiffraction.analysis.enums import FitResultKindEnum + + collection = _fit_parameters_with_parent_result_kind(FitResultKindEnum.DETERMINISTIC.value) + collection['cosio.cell.length_a']._set_fit_bounds_uncertainty_multiplier(4.0) + + cif_text = collection.as_cif + + assert '_fit_parameter.fit_bounds_uncertainty_multiplier' in cif_text + assert '4.' in cif_text + + +def test_fit_parameters_cif_keeps_posterior_columns_for_bayesian_result(): + from easydiffraction.analysis.enums import FitResultKindEnum + from easydiffraction.core.posterior import PosteriorParameterSummary + + collection = _fit_parameters_with_parent_result_kind(FitResultKindEnum.BAYESIAN.value) + collection.set_posterior_summary( + PosteriorParameterSummary( + unique_name='cosio.cell.length_a', + display_name='a', + best_sample_value=10.1, + median=10.2, + standard_deviation=0.3, + interval_68=(9.9, 10.5), + interval_95=(9.7, 10.7), + ess_bulk=80.0, + r_hat=1.01, + ) + ) + + cif_text = collection.as_cif + + assert '_fit_parameter.posterior_median' in cif_text + assert '_fit_parameter.posterior_effective_sample_size_bulk' in cif_text + assert '10.2' in cif_text diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test__diagnostics.py b/tests/unit/easydiffraction/analysis/fit_helpers/test__diagnostics.py new file mode 100644 index 000000000..5503009d3 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test__diagnostics.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from easydiffraction.analysis.fit_helpers._diagnostics import _autocorr_fft +from easydiffraction.analysis.fit_helpers._diagnostics import compute_ess_bulk +from easydiffraction.analysis.fit_helpers._diagnostics import compute_r_hat + + +def _independent_samples(n_draws: int, n_chains: int, seed: int) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.standard_normal((n_draws, n_chains)) + + +def _correlated_chain(n_draws: int, n_chains: int, rho: float, seed: int) -> np.ndarray: + """Generate AR(1) chains with autocorrelation ``rho``.""" + rng = np.random.default_rng(seed) + samples = np.zeros((n_draws, n_chains)) + samples[0, :] = rng.standard_normal(n_chains) + sigma = math.sqrt(1.0 - rho * rho) + for t in range(1, n_draws): + samples[t, :] = rho * samples[t - 1, :] + sigma * rng.standard_normal(n_chains) + return samples + + +def test_r_hat_returns_nan_for_too_few_draws_or_chains(): + assert math.isnan(compute_r_hat(np.ones((3, 4)))) + assert math.isnan(compute_r_hat(np.ones((100, 1)))) + + +def test_r_hat_returns_nan_for_zero_variance(): + assert math.isnan(compute_r_hat(np.ones((100, 4)))) + + +def test_r_hat_rejects_non_2d_arrays(): + with pytest.raises(ValueError, match=r'samples must have shape \(n_draws, n_chains\)'): + compute_r_hat(np.ones((10, 4, 2))) + + +def test_r_hat_close_to_one_for_well_mixed_independent_chains(): + samples = _independent_samples(n_draws=2000, n_chains=4, seed=42) + r_hat = compute_r_hat(samples) + + assert math.isfinite(r_hat) + assert 0.98 < r_hat < 1.05 + + +def test_r_hat_above_one_when_chains_disagree(): + rng = np.random.default_rng(1) + chains_with_offsets = rng.standard_normal((1000, 4)) + np.array([-2.0, -1.0, 1.0, 2.0]) + r_hat = compute_r_hat(chains_with_offsets) + + assert math.isfinite(r_hat) + assert r_hat > 1.5 + + +def test_r_hat_handles_odd_number_of_draws(): + samples = _independent_samples(n_draws=2001, n_chains=4, seed=7) + r_hat = compute_r_hat(samples) + + assert math.isfinite(r_hat) + assert 0.97 < r_hat < 1.05 + + +def test_ess_bulk_returns_nan_for_too_few_draws(): + assert math.isnan(compute_ess_bulk(np.ones((3, 4)))) + + +def test_ess_bulk_rejects_non_2d_arrays(): + with pytest.raises(ValueError, match=r'samples must have shape \(n_draws, n_chains\)'): + compute_ess_bulk(np.ones((10, 4, 2))) + + +def test_ess_bulk_near_total_for_independent_samples(): + samples = _independent_samples(n_draws=2000, n_chains=4, seed=123) + ess = compute_ess_bulk(samples) + + assert math.isfinite(ess) + # Rank-normalized ACF on truly independent samples is dominated by + # noise around zero; the Geyer initial-positive-sequence sum truncates + # quickly and ESS is close to the total sample count. + assert ess > 0.5 * samples.size + + +def test_ess_bulk_lower_for_strongly_autocorrelated_chains(): + independent = _independent_samples(n_draws=2000, n_chains=4, seed=11) + correlated = _correlated_chain(n_draws=2000, n_chains=4, rho=0.9, seed=11) + + ess_independent = compute_ess_bulk(independent) + ess_correlated = compute_ess_bulk(correlated) + + assert math.isfinite(ess_independent) + assert math.isfinite(ess_correlated) + assert ess_correlated < 0.4 * ess_independent + + +def test_autocorr_fft_handles_zero_variance(): + flat = np.ones(64) + acf = _autocorr_fft(flat) + + # Zero-variance input: ACF is all zeros except the lag-0 element. + assert acf[0] == 0.0 diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py index 348f380f3..da29f65ba 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py @@ -31,7 +31,7 @@ def test_module_import(): assert MUT.__name__ == 'easydiffraction.analysis.fit_helpers.bayesian' -def test_posterior_samples_flatten_and_to_arviz(): +def test_posterior_samples_flatten(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples posterior_samples = PosteriorSamples( @@ -47,17 +47,25 @@ def test_posterior_samples_flatten_and_to_arviz(): ) flattened = posterior_samples.flattened() - inference_data = posterior_samples.to_arviz() assert flattened.shape == (4, 2) np.testing.assert_allclose(flattened[:, 0], np.array([1.0, 2.0, 3.0, 4.0])) np.testing.assert_allclose(flattened[:, 1], np.array([10.0, 20.0, 30.0, 40.0])) - assert set(inference_data.posterior.data_vars) == {'a', 'b'} - assert inference_data.posterior['a'].shape == (2, 2) - assert inference_data.sample_stats['lp'].shape == (2, 2) -def test_posterior_samples_to_arviz_validates_shapes(): +def test_posterior_samples_validate_shapes_returns_dimensions(): + from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples + + posterior_samples = PosteriorSamples( + parameter_names=['a'], + parameter_samples=np.ones((2, 32, 1), dtype=float), + log_posterior=np.ones((2, 32), dtype=float), + ) + + assert posterior_samples.validate_shapes() == (2, 32, 1) + + +def test_posterior_samples_validate_shapes_rejects_wrong_ndim(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples posterior_samples = PosteriorSamples( @@ -69,7 +77,7 @@ def test_posterior_samples_to_arviz_validates_shapes(): ValueError, match=r'Posterior sample array must have shape \(n_draws, n_chains, n_parameters\)\.', ): - posterior_samples.to_arviz() + posterior_samples.validate_shapes() def test_compute_convergence_diagnostics_treats_non_finite_values_as_not_converged(monkeypatch): @@ -81,17 +89,13 @@ def test_compute_convergence_diagnostics_treats_non_finite_values_as_not_converg parameter_samples=np.ones((4, 2, 1), dtype=float), ) - fake_dataset = type('FakeDataset', (), {'data_vars': {'a': np.array([np.nan], dtype=float)}}) - monkeypatch.setattr( - 'easydiffraction.analysis.fit_helpers.bayesian.az.rhat', - lambda inference_data: fake_dataset, + 'easydiffraction.analysis.fit_helpers.bayesian.compute_r_hat', + lambda _samples: float('nan'), ) monkeypatch.setattr( - 'easydiffraction.analysis.fit_helpers.bayesian.az.ess', - lambda inference_data, method='bulk': type( - 'FakeDataset', (), {'data_vars': {'a': np.array([4000.0], dtype=float)}} - ), + 'easydiffraction.analysis.fit_helpers.bayesian.compute_ess_bulk', + lambda _samples: 4000.0, ) diagnostics = compute_convergence_diagnostics(posterior_samples) @@ -188,22 +192,25 @@ def test_bayesian_fit_results_display_results_prints_sampler_and_convergence(cap out = capsys.readouterr().out assert 'Bayesian fit results' in out - assert 'Overall status: completed with warnings' in out - assert 'Sampler status: DREAM sampling completed' in out - assert 'Sampler: dream' in out - assert 'Sampler completed: yes' in out - assert 'steps=200' in out - assert 'init=lhs' in out - assert 'random_seed=1313900679' not in out - assert 'status=failed' in out - assert 'max_r_hat=1.107' in out - assert 'min_ess_bulk=125.9' in out - assert 'Posterior parameter summaries:' in out + assert '❌ Overall status' in out + assert '✅ Overall status' not in out + assert 'failed' in out # convergence failed → overall failed + assert 'DREAM sampling completed' in out # engine message + assert 'Sampler' in out + assert 'Convergence status' in out + assert 'Max r-hat' in out + assert '1.107' in out + assert 'Min ess bulk' in out + assert '125.9' in out + assert 'Posterior distribution:' in out assert 'Success: True' not in out + assert 'Sampler completed' not in out # dropped — redundant with Overall status + assert 'Sampler settings' not in out # dropped — covered by Settings used table + assert 'Committed point estimate' not in out # dropped — covered by footnote assert 'datablock' in out assert 'category' in out assert 'entry' in out - assert '95% interval' in out + assert '95% CI' in out assert '68% interval' not in out assert 'std' not in out @@ -265,8 +272,8 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'start', - 'best posterior sample', - 'uncertainty', + 'value', + 's.u.', 'change', ] assert captured['columns_alignment'] == [ @@ -332,7 +339,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'median', - '95% interval', + '95% CI', 'r-hat', 'ess bulk', ] diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py index 9758f70f8..d5a241d17 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py @@ -10,6 +10,13 @@ def test_module_import(): assert expected_module_name == actual_module_name +def test_overall_status_row_label_uses_failure_icon(): + from easydiffraction.analysis.fit_helpers.reporting import _overall_status_row_label + + assert _overall_status_row_label('success') == '✅ Overall status' + assert _overall_status_row_label('failed') == '❌ Overall status' + + def test_fitresults_display_results_prints_and_table(capsys, monkeypatch): # Arrange: build a minimal fake parameter object with required attributes class Identity: @@ -49,14 +56,16 @@ def __init__(self, start, value, uncertainty, name='p', units='u'): # Assert: key lines printed and a table rendered out = capsys.readouterr().out - assert 'Fit results' in out - assert 'Success: True' in out + assert 'Least-squares fit results:' in out + assert '✅ Overall status' in out + assert 'success' in out assert 'reduced χ²' in out - assert 'R-factor (Rf)' in out - assert 'R-factor squared (Rf²)' in out - assert 'Weighted R-factor (wR)' in out - assert 'Bragg R-factor (BR)' in out - assert 'Fitted parameters:' in out + assert 'R-factor (Rf' in out + assert 'R-factor squared (Rf²' in out + assert 'Weighted R-factor (wR' in out + assert 'Bragg R-factor (BR' in out + assert 'Refined parameters:' in out + assert 'Success: True' not in out # replaced by Overall status row # Table border: accept common border glyphs from Rich assert any(ch in out for ch in ('╒', '┌', '+', '─')) @@ -97,8 +106,8 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'parameter', 'units', 'start', - 'fitted', - 'uncertainty', + 'value', + 's.u.', 'change', ] assert captured['columns_alignment'] == [ diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py index ff9df3288..59c5c22bd 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from types import SimpleNamespace + import numpy as np @@ -124,3 +126,80 @@ def test_tracker_sampler_post_processing_adds_final_status_row(): assert tracker._df_rows[-1][1] == '' assert tracker._df_rows[-1][3] == '' assert tracker._df_rows[-1][4] == 'post-processing' + + +def test_notebook_fit_stop_control_renders_interrupt_button(monkeypatch): + import easydiffraction.display.progress as progress_mod + from easydiffraction.utils.enums import VerbosityEnum + + html_updates: list[str] = [] + javascript_outputs: list[str] = [] + + class FakeDisplayHandle: + def display(self, value: SimpleNamespace) -> None: + html_updates.append(value.data) + + def update(self, value: SimpleNamespace) -> None: + html_updates.append(value.data) + + monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: True) + monkeypatch.setattr(progress_mod, 'HTML', lambda data: SimpleNamespace(data=data)) + monkeypatch.setattr( + progress_mod, + 'Javascript', + lambda data: SimpleNamespace(data=data), + ) + monkeypatch.setattr(progress_mod, 'DisplayHandle', FakeDisplayHandle) + monkeypatch.setattr( + progress_mod, + 'display', + lambda value: javascript_outputs.append(value.data), + ) + + with progress_mod.notebook_fit_stop_control(verbosity=VerbosityEnum.FULL): + pass + + assert 'Stop fitting' in html_updates[0] + assert 'api/kernels/' in javascript_outputs[0] + assert 'Interrupt sent...' in javascript_outputs[0] + assert html_updates[-1] == '' + + +def test_notebook_fit_stop_control_clears_button_after_interrupt(monkeypatch): + import easydiffraction.display.progress as progress_mod + from easydiffraction.utils.enums import VerbosityEnum + + html_updates: list[str] = [] + + class FakeDisplayHandle: + def display(self, value: SimpleNamespace) -> None: + html_updates.append(value.data) + + def update(self, value: SimpleNamespace) -> None: + html_updates.append(value.data) + + monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: True) + monkeypatch.setattr(progress_mod, 'HTML', lambda data: SimpleNamespace(data=data)) + monkeypatch.setattr( + progress_mod, + 'Javascript', + lambda data: SimpleNamespace(data=data), + ) + monkeypatch.setattr(progress_mod, 'DisplayHandle', FakeDisplayHandle) + monkeypatch.setattr(progress_mod, 'display', lambda value: None) + + try: + with progress_mod.notebook_fit_stop_control(verbosity=VerbosityEnum.FULL): + raise KeyboardInterrupt + except KeyboardInterrupt: + pass + + assert html_updates[-1] == '' + + +def test_notebook_fit_stop_control_extracts_kernel_id_from_connection_file(): + from easydiffraction.display.progress import NotebookFitStopControl + + kernel_id = NotebookFitStopControl._kernel_id_from_connection_file('kernel-abc-123.json') + + assert kernel_id == 'abc-123' diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_base.py b/tests/unit/easydiffraction/analysis/minimizers/test_base.py index 4ee99f1d1..50a353d69 100644 --- a/tests/unit/easydiffraction/analysis/minimizers/test_base.py +++ b/tests/unit/easydiffraction/analysis/minimizers/test_base.py @@ -237,6 +237,7 @@ def _check_success(self, raw_result): def test_minimizer_base_stop_tracking_backfills_result_fitting_time(): from easydiffraction.analysis.minimizers.base import MinimizerBase + from easydiffraction.analysis.minimizers.base import MinimizerFitOptions class DummyResult: success = True @@ -280,7 +281,7 @@ def _compute_residuals( result = minimizer.fit( parameters=params, objective_function=objective, - finalize_tracking=False, + options=MinimizerFitOptions(finalize_tracking=False), ) assert result.fitting_time is None diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_emcee.py b/tests/unit/easydiffraction/analysis/minimizers/test_emcee.py new file mode 100644 index 000000000..46c0e2dd2 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/minimizers/test_emcee.py @@ -0,0 +1,429 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the emcee minimizer engine.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + + +class _FakeTracker: + """Progress tracker test double.""" + + best_chi2 = 1.23 + + def __init__(self) -> None: + self.updates = [] + + def _current_elapsed_time(self) -> float: + return float(len(self.updates) + 1) + + def track_sampler_progress(self, update: object) -> None: + self.updates.append(update) + + +class _FakeSampler: + """Minimal sampler test double for the emcee sample loop.""" + + def __init__(self) -> None: + self.calls = [] + + def sample( + self, + initial_state: object, + *, + iterations: int, + skip_initial_state_check: bool, + progress: bool, + ) -> object: + self.calls.append({ + 'initial_state': initial_state, + 'iterations': iterations, + 'skip_initial_state_check': skip_initial_state_check, + 'progress': progress, + }) + for index in range(iterations): + yield SimpleNamespace(log_prob=np.array([float(index)], dtype=float)) + + +def test_emcee_minimizer_defaults_to_max_parallel_workers(): + from easydiffraction.analysis.minimizers.emcee import DEFAULT_PARALLEL_WORKERS + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + + assert DEFAULT_PARALLEL_WORKERS == 0 + assert minimizer.parallel_workers == 0 + + +def test_emcee_minimizer_defaults_to_de_without_thinning(): + from easydiffraction.analysis.minimizers.emcee import DEFAULT_PROPOSAL_MOVES + from easydiffraction.analysis.minimizers.emcee import DEFAULT_THIN + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + + assert DEFAULT_PROPOSAL_MOVES == 'de' + assert DEFAULT_THIN == 1 + assert minimizer.proposal_moves == 'de' + assert minimizer.thin == 1 + + +def test_emcee_best_sample_reduced_chi_square_uses_objective_residuals(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + reduced_chi_square = EmceeMinimizer._best_sample_reduced_chi_square( + objective_function=lambda params: np.asarray( + [params['a'] - 1.0, params['b'] - 2.0, 2.0, 4.0], + dtype=float, + ), + parameter_names=['a', 'b'], + best_sample_values=np.asarray([2.0, 4.0], dtype=float), + ) + + assert reduced_chi_square == pytest.approx(12.5) + + +def test_emcee_build_fit_results_prefers_raw_reduced_chi_square(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + raw_result = SimpleNamespace( + reduced_chi_square=1.25, + raw_state=object(), + sampler_settings={}, + convergence_diagnostics={}, + posterior_parameter_summaries=[], + sampler_completed=True, + best_log_posterior=-10.0, + message='emcee sampling completed', + ) + + fit_results = minimizer._build_fit_results( + parameters=[], + raw_result=raw_result, + success=True, + ) + + assert fit_results.reduced_chi_square == 1.25 + + +def test_emcee_pool_context_uses_fork_worker_for_unpicklable_objective(monkeypatch): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + from easydiffraction.analysis.minimizers.emcee import _emcee_log_prob_worker + + class FakePool: + def __init__(self) -> None: + self.closed = False + self.joined = False + + def close(self) -> None: + self.closed = True + + def join(self) -> None: + self.joined = True + + class FakeContext: + def __init__(self) -> None: + self.pool = FakePool() + self.worker_count = None + + def Pool(self, worker_count: int) -> FakePool: # noqa: N802 + self.worker_count = worker_count + return self.pool + + fake_context = FakeContext() + minimizer = EmceeMinimizer() + minimizer.parallel_workers = 2 + + monkeypatch.setattr( + EmceeMinimizer, + '_fork_context_available', + staticmethod(lambda: True), + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.emcee.multiprocessing.get_context', + lambda name: fake_context, + ) + + log_prob = minimizer._build_log_probability( + parameters=[SimpleNamespace(fit_min=-10.0, fit_max=10.0)], + parameter_names=['p'], + objective_function=lambda values: np.array([values['p']], dtype=float), + ) + pool_context = minimizer._build_pool_context(log_prob) + + assert fake_context.worker_count == 2 + assert pool_context.pool is fake_context.pool + assert pool_context.log_prob_fn is _emcee_log_prob_worker + assert pool_context.log_prob_fn(np.array([2.0], dtype=float)) == pytest.approx(-2.0) + + minimizer._close_pool_context(pool_context) + + assert fake_context.pool.closed is True + assert fake_context.pool.joined is True + with pytest.raises(RuntimeError, match='has not been initialized'): + _emcee_log_prob_worker(np.array([2.0], dtype=float)) + + +def test_emcee_pool_context_terminates_on_interrupt_cleanup(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + from easydiffraction.analysis.minimizers.emcee import _EmceePoolContext + + class FakePool: + def __init__(self) -> None: + self.closed = False + self.terminated = False + self.joined = False + + def close(self) -> None: + self.closed = True + + def terminate(self) -> None: + self.terminated = True + + def join(self) -> None: + self.joined = True + + fake_pool = FakePool() + pool_context = _EmceePoolContext(pool=fake_pool, log_prob_fn=lambda values: 0.0) + + EmceeMinimizer._close_pool_context(pool_context, terminate=True) + + assert fake_pool.terminated is True + assert fake_pool.closed is False + assert fake_pool.joined is True + + +def test_emcee_run_solver_terminates_pool_when_interrupted(monkeypatch, tmp_path): + import easydiffraction.analysis.minimizers.emcee as emcee_mod + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + from easydiffraction.analysis.minimizers.emcee import _EmceePoolContext + + class FakeBackend: + iteration = 0 + + def __init__( + self, + path: str, + *, + name: str, + read_only: bool, + ) -> None: + del path, name, read_only + + class FakePool: + def __init__(self) -> None: + self.terminated = False + self.joined = False + + def terminate(self) -> None: + self.terminated = True + + def join(self) -> None: + self.joined = True + + fake_pool = FakePool() + minimizer = EmceeMinimizer() + minimizer.tracker = SimpleNamespace(start_sampler_pre_processing=lambda **kwargs: None) + + monkeypatch.setattr( + emcee_mod.emcee.backends, + 'HDFBackend', + FakeBackend, + ) + monkeypatch.setattr( + minimizer, + '_resolved_sidecar_path', + lambda: tmp_path / 'analysis' / 'results.h5', + ) + monkeypatch.setattr(minimizer, '_validate_walker_count', lambda **kwargs: None) + monkeypatch.setattr( + minimizer, + '_build_log_probability', + lambda **kwargs: object(), + ) + monkeypatch.setattr( + minimizer, + '_build_pool_context', + lambda log_prob: _EmceePoolContext(pool=fake_pool, log_prob_fn=lambda values: 0.0), + ) + monkeypatch.setattr( + minimizer, + '_run_sampler', + lambda **kwargs: (_ for _ in ()).throw(KeyboardInterrupt), + ) + + with pytest.raises(KeyboardInterrupt): + minimizer._run_solver( + lambda values: np.array([0.0], dtype=float), + parameters=[SimpleNamespace(fit_min=-1.0, fit_max=1.0)], + parameter_names=['p'], + parameter_display_names=['p'], + random_seed=1, + resume=False, + extra_steps=None, + starting_values=[0.0], + starting_uncertainties=[None], + ) + + assert fake_pool.terminated is True + assert fake_pool.joined is True + + +def test_emcee_progress_reporter_emits_burn_in_and_sampling_updates(): + from easydiffraction.analysis.minimizers.emcee import _EmceeProgressReporter + + tracker = _FakeTracker() + reporter = _EmceeProgressReporter(tracker=tracker, total_steps=10, burn_steps=2) + state = SimpleNamespace(log_prob=np.array([-10.0, -5.0], dtype=float)) + + for iteration in range(1, 11): + reporter.report(iteration=iteration, state=state) + + assert len(tracker.updates) > 2 + assert tracker.updates[0].iteration == 1 + assert tracker.updates[0].phase == 'burn-in' + assert any(update.phase == 'sampling' for update in tracker.updates) + assert tracker.updates[-1].iteration == 10 + assert tracker.updates[-1].progress_percent == pytest.approx(100.0) + assert tracker.updates[-1].log_posterior == pytest.approx(-5.0) + assert tracker.updates[-1].reduced_chi2 == pytest.approx(1.23) + + +def test_emcee_progress_reporter_treats_resume_as_sampling_only(): + from easydiffraction.analysis.minimizers.emcee import _EmceeProgressReporter + + tracker = _FakeTracker() + reporter = _EmceeProgressReporter(tracker=tracker, total_steps=5, burn_steps=0) + state = SimpleNamespace(log_prob=np.array([-2.0, -1.0], dtype=float)) + + for iteration in range(1, 6): + reporter.report(iteration=iteration, state=state) + + assert tracker.updates + assert {update.phase for update in tracker.updates} == {'sampling'} + + +def test_emcee_total_iterations_adds_burn_in_and_initial_generation(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + minimizer.nsteps = 100 + minimizer.nburn = 20 + + assert minimizer._resolved_total_iterations(resume=False, extra_steps=None) == 121 + assert minimizer._resolved_total_iterations(resume=True, extra_steps=50) == 50 + + +def test_emcee_run_sampler_resumes_from_backend_last_sample(monkeypatch): + import easydiffraction.analysis.minimizers.emcee as emcee_mod + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + class FakeEnsembleSampler: + def __init__(self, **kwargs) -> None: + del kwargs + self.fake_sampler = _FakeSampler() + + def sample(self, *args, **kwargs): + return self.fake_sampler.sample(*args, **kwargs) + + backend = SimpleNamespace( + shape=(4, 2), + iteration=3, + reset=lambda *args: (_ for _ in ()).throw(AssertionError('no reset')), + ) + last_sample = SimpleNamespace(log_prob=np.array([-1.0, -2.0], dtype=float)) + backend.get_last_sample = lambda: last_sample + minimizer = EmceeMinimizer() + minimizer.nwalkers = 4 + minimizer.tracker = _FakeTracker() + created_samplers: list[FakeEnsembleSampler] = [] + + def fake_sampler_factory(**kwargs): + sampler = FakeEnsembleSampler(**kwargs) + created_samplers.append(sampler) + return sampler + + monkeypatch.setattr(emcee_mod.emcee, 'EnsembleSampler', fake_sampler_factory) + + minimizer._run_sampler( + backend=backend, + log_prob=lambda values: -1.0, + pool=None, + parameters=[], + n_parameters=2, + random_seed=1, + resume=True, + extra_steps=2, + total_iterations=2, + ) + + assert created_samplers[0].fake_sampler.calls == [ + { + 'initial_state': last_sample, + 'iterations': 2, + 'skip_initial_state_check': True, + 'progress': False, + } + ] + assert [update.iteration for update in minimizer.tracker.updates] == [1, 2] + + +def test_emcee_sampler_settings_record_sampling_and_total_steps(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + minimizer = EmceeMinimizer() + minimizer.nsteps = 100 + minimizer.nburn = 20 + + settings = minimizer._sampler_settings( + random_seed=123, + total_steps=121, + n_parameters=2, + ) + + assert settings['nsteps'] == 100 + assert settings['nburn'] == 20 + assert settings['nwalkers'] == minimizer.nwalkers + assert settings['parallel_workers'] == minimizer.parallel_workers + assert settings['initialization_method'] == 'ball' + assert settings['proposal_moves'] == 'de' + assert settings['total_steps'] == 121 + assert settings['samples'] == 100 * minimizer.nwalkers * 2 + assert 'steps' not in settings + assert 'burn' not in settings + assert 'pop' not in settings + + +def test_sample_with_progress_iterates_sampler_and_reports_each_state(): + from easydiffraction.analysis.minimizers.emcee import EmceeMinimizer + + sampler = _FakeSampler() + reporter = SimpleNamespace(updates=[]) + + def report(*, iteration: int, state: object) -> None: + reporter.updates.append((iteration, state.log_prob.copy())) + + reporter.report = report + + EmceeMinimizer._sample_with_progress( + sampler=sampler, + initial_state=None, + iterations=3, + reporter=reporter, + skip_initial_state_check=True, + ) + + assert sampler.calls == [ + { + 'initial_state': None, + 'iterations': 3, + 'skip_initial_state_check': True, + 'progress': False, + } + ] + assert [iteration for iteration, _log_prob in reporter.updates] == [1, 2, 3] diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_emcee_defaults.py b/tests/unit/easydiffraction/analysis/minimizers/test_emcee_defaults.py new file mode 100644 index 000000000..1e50a0bb1 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/minimizers/test_emcee_defaults.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for shared emcee minimizer defaults.""" + +from __future__ import annotations + + +def test_emcee_defaults_match_category_and_engine_imports(): + from easydiffraction.analysis.categories.minimizer import emcee as category_defaults + from easydiffraction.analysis.minimizers import emcee as engine_defaults + from easydiffraction.analysis.minimizers import emcee_defaults + + assert category_defaults.DEFAULT_SAMPLING_STEPS == emcee_defaults.DEFAULT_NSTEPS + assert category_defaults.DEFAULT_BURN_IN_STEPS == emcee_defaults.DEFAULT_NBURN + assert category_defaults.DEFAULT_POPULATION_SIZE == emcee_defaults.DEFAULT_NWALKERS + assert category_defaults.DEFAULT_PROPOSAL_MOVES == emcee_defaults.DEFAULT_PROPOSAL_MOVES + assert engine_defaults.DEFAULT_NSTEPS == emcee_defaults.DEFAULT_NSTEPS + assert engine_defaults.DEFAULT_NBURN == emcee_defaults.DEFAULT_NBURN + assert engine_defaults.DEFAULT_NWALKERS == emcee_defaults.DEFAULT_NWALKERS + assert engine_defaults.DEFAULT_PROPOSAL_MOVES == emcee_defaults.DEFAULT_PROPOSAL_MOVES diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 9d7e5b94d..01f54ce0c 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -24,6 +24,7 @@ def names(self): class P: experiments = ExpCol(names) structures = object() + info = SimpleNamespace(path=None) _varname = 'proj' return P() @@ -100,10 +101,21 @@ def test_minimizer_selector_swap_warns_for_different_defaults(monkeypatch): # Inter-family swap should split warnings into "removed"/"added" # lines rather than emitting "" sentinels per # finding F3. - assert any('removes these settings' in w and 'max_iterations' in w for w in warnings) - assert any( - 'adds these settings with defaults' in w and 'sampling_steps' in w for w in warnings + removed_warning = next(w for w in warnings if 'removes these settings' in w) + added_warning = next(w for w in warnings if 'adds these settings' in w) + assert removed_warning == ( + 'Switching minimizer type removes these settings:\n• max_iterations' ) + assert added_warning.splitlines() == [ + 'Switching minimizer type adds these settings with defaults:', + '• burn_in_steps=600', + "• initialization_method='latin_hypercube'", + '• parallel_workers=0', + '• population_size=4', + '• random_seed=None', + '• sampling_steps=3000', + '• thinning_interval=1', + ] assert not any('' in w for w in warnings) @@ -121,6 +133,235 @@ def test_minimizer_type_invalid_assignment_raises_and_preserves_state(): assert a.minimizer.type == initial_type +def test_store_posterior_projection_persists_resolved_random_seed(): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult + from easydiffraction.analysis.fit_helpers.bayesian import BayesianFitResults + + analysis = Analysis(project=_make_project_with_names([])) + analysis._fit_result._parent = None + analysis._fit_result = BayesianFitResult() + analysis._fit_result._parent = analysis + results = BayesianFitResults( + success=True, + convergence_diagnostics={}, + sampler_settings={'random_seed': 12345}, + posterior_samples=None, + posterior_parameter_summaries=[], + ) + + analysis._store_posterior_fit_projection(results) + + assert analysis.fit_result.resolved_random_seed.value == 12345 + + +def test_restored_bayesian_diagnostics_reconstruct_passed_status(): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult + + analysis = Analysis(project=_make_project_with_names([])) + analysis._fit_result._parent = None + analysis._fit_result = BayesianFitResult() + analysis._fit_result._parent = analysis + analysis.fit_result._set_gelman_rubin_max(1.002) + analysis.fit_result._set_effective_sample_size_min(8810.5) + analysis.fit_result._set_acceptance_rate_mean(0.3) + + diagnostics = analysis._restored_bayesian_convergence_diagnostics( + sample_shape=(10001, 16, 5), + n_parameters=5, + ) + + assert diagnostics['converged'] is True + assert diagnostics['max_r_hat'] == 1.002 + assert diagnostics['min_ess_bulk'] == 8810.5 + assert diagnostics['acceptance_rate_mean'] == 0.3 + assert diagnostics['n_draws'] == 10001 + assert diagnostics['n_chains'] == 16 + assert diagnostics['n_parameters'] == 5 + + +def test_restored_bayesian_sampler_settings_reconstruct_sample_count(): + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_names([])) + analysis.minimizer.type = 'emcee' + + settings = analysis._restored_bayesian_sampler_settings( + { + 'nsteps': 10000, + 'nburn': 2000, + 'thin': 1, + 'nwalkers': 16, + 'parallel_workers': 0, + 'initialization_method': 'ball', + 'proposal_moves': 'de', + }, + random_seed=123, + n_parameters=5, + ) + + assert settings['nsteps'] == 10000 + assert settings['nburn'] == 2000 + assert settings['thin'] == 1 + assert settings['nwalkers'] == 16 + assert settings['parallel_workers'] == 0 + assert settings['initialization_method'] == 'ball' + assert settings['proposal_moves'] == 'de' + assert settings['samples'] == 800000 + assert settings['random_seed'] == 123 + + +def test_emcee_fit_requires_saved_project_for_new_and_resume_runs(): + import pytest + + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_names([])) + analysis.minimizer.type = 'emcee' + + with pytest.raises(ValueError, match='emcee requires a saved project'): + analysis.fit() + + with pytest.raises(ValueError, match='emcee requires a saved project'): + analysis.fit(resume=True) + + +def test_restored_bayesian_reduced_chi_square_recovers_from_log_posterior(monkeypatch): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.categories.fit_result.bayesian import BayesianFitResult + + analysis = Analysis(project=_make_project_with_names([])) + analysis._fit_result._parent = None + analysis._fit_result = BayesianFitResult() + analysis._fit_result._parent = analysis + analysis.fit_result._set_best_log_posterior(-50.0) + monkeypatch.setattr(analysis, '_fit_data_point_count', lambda experiments: 102) + + reduced_chi_square = analysis._restored_bayesian_reduced_chi_square( + float('nan'), + restored_parameters=[object(), object()], + ) + + assert reduced_chi_square == 1.0 + + +def test_fit_interrupt_cleans_state_and_prints_message(monkeypatch, capsys): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + events: list[object] = [] + + class FakeStopControl: + def __enter__(self) -> object: + events.append('enter') + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + del exc_value + del traceback + events.append(exc_type) + + analysis = Analysis(project=_make_project_with_names([])) + analysis.project.verbosity = SimpleNamespace(fit=SimpleNamespace(value='full')) + analysis.fit_results = object() + analysis.fitter.results = object() + + monkeypatch.setattr( + analysis_mod, + 'notebook_fit_stop_control', + lambda *, verbosity: FakeStopControl(), + ) + monkeypatch.setattr( + analysis, + '_run_single', + lambda **kwargs: (_ for _ in ()).throw(KeyboardInterrupt), + ) + monkeypatch.setattr( + analysis, + '_prepare_results_sidecar_for_new_fit', + lambda: events.append('sidecar-cleanup'), + ) + + analysis.fit() + + assert events == ['enter', KeyboardInterrupt, 'sidecar-cleanup'] + assert analysis.fit_results is None + assert analysis.fitter.results is None + assert 'Fitting stopped by user.' in capsys.readouterr().out + + +def test_fit_resume_defaults_extra_steps_to_sampling_steps(monkeypatch, tmp_path): + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_names(['e1'])) + analysis.project.verbosity = SimpleNamespace(fit=SimpleNamespace(value='silent')) + analysis.project.info = SimpleNamespace(path=tmp_path) + analysis.minimizer.type = 'emcee' + analysis.minimizer.sampling_steps = 123 + captured: dict[str, object] = {} + + monkeypatch.setattr(analysis, '_has_resumable_emcee_sidecar', lambda: True) + monkeypatch.setattr( + analysis, + '_run_single', + lambda **kwargs: captured.update(kwargs), + ) + + analysis.fit(resume=True) + + assert captured == {'resume': True, 'extra_steps': 123} + + +def test_fit_resume_preserves_explicit_extra_steps(monkeypatch, tmp_path): + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_names(['e1'])) + analysis.project.verbosity = SimpleNamespace(fit=SimpleNamespace(value='silent')) + analysis.project.info = SimpleNamespace(path=tmp_path) + analysis.minimizer.type = 'emcee' + analysis.minimizer.sampling_steps = 123 + captured: dict[str, object] = {} + + monkeypatch.setattr(analysis, '_has_resumable_emcee_sidecar', lambda: True) + monkeypatch.setattr( + analysis, + '_run_single', + lambda **kwargs: captured.update(kwargs), + ) + + analysis.fit(resume=True, extra_steps=10) + + assert captured == {'resume': True, 'extra_steps': 10} + + +def test_fit_resume_missing_sidecar_warns_and_starts_fresh( + monkeypatch, + tmp_path, +): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_names(['e1'])) + analysis.project.verbosity = SimpleNamespace(fit=SimpleNamespace(value='silent')) + analysis.project.info = SimpleNamespace(path=tmp_path) + analysis.minimizer.type = 'emcee' + captured: dict[str, object] = {} + warnings: list[str] = [] + + monkeypatch.setattr(analysis_mod.log, 'warning', warnings.append) + monkeypatch.setattr( + analysis, + '_run_single', + lambda **kwargs: captured.update(kwargs), + ) + + analysis.fit(resume=True) + + assert captured == {'resume': False, 'extra_steps': None} + assert any('no saved emcee chain' in message for message in warnings) + + def test_fitting_mode_type_invalid_assignment_raises_and_preserves_state(): import pytest @@ -234,6 +475,7 @@ def values(self): def test_fit_single_short_reuses_tracker_display_handle(monkeypatch): from easydiffraction.analysis.analysis import Analysis + from easydiffraction.analysis.fitting import FitterFitOptions from easydiffraction.utils.enums import VerbosityEnum class Handle: @@ -281,10 +523,15 @@ def fake_fit( *, analysis: object, verbosity: object, - use_physical_limits: bool, - random_seed: int | None, + options: FitterFitOptions, ) -> None: - del structures, experiments, analysis, verbosity, use_physical_limits, random_seed + del ( + structures, + experiments, + analysis, + verbosity, + options, + ) analysis_obj = fake_fit.analysis_obj analysis_obj.fitter.results = SimpleNamespace( reduced_chi_square=1.23, @@ -316,8 +563,7 @@ def fake_update_short_table( VerbosityEnum.SHORT, project.structures, project.experiments, - use_physical_limits=False, - random_seed=None, + fit_options=FitterFitOptions(), ) assert tracker.display_handles == [handle, None] diff --git a/tests/unit/easydiffraction/analysis/test_fitting.py b/tests/unit/easydiffraction/analysis/test_fitting.py index 72d2c7724..d5995f5ef 100644 --- a/tests/unit/easydiffraction/analysis/test_fitting.py +++ b/tests/unit/easydiffraction/analysis/test_fitting.py @@ -144,30 +144,38 @@ def __init__(self): self.fit_calls: list[dict[str, object]] = [] self.stop_calls = 0 self.tracker = SimpleNamespace(track=lambda residuals, parameters: residuals) + self.result = None def fit(self, params, obj, verbosity=None, **kwargs): del params, obj self.fit_calls.append({'verbosity': verbosity, **kwargs}) - return BayesianFitResults( + self.result = BayesianFitResults( success=True, reduced_chi_square=1.2, convergence_diagnostics={'converged': False}, sampler_settings={'steps': 300}, best_log_posterior=-10.0, ) + return self.result def _stop_tracking(self): + analysis_events.append('stop') self.stop_calls += 1 def _finalize_timing(self): - pass + analysis_events.append('finalize') + self.result.fitting_time = 12.5 analysis_events: list[str] = [] + fit_result = SimpleNamespace( + _set_fitting_time=lambda value: analysis_events.append(('time', value)), + ) analysis = SimpleNamespace( _capture_fit_parameter_state=lambda params: analysis_events.append('capture'), _store_fit_result_projection=lambda results, experiments, fitted_parameters: ( analysis_events.append('store') ), + fit_result=fit_result, ) fitter = Fitter() @@ -185,9 +193,9 @@ def _finalize_timing(self): verbosity=VerbosityEnum.FULL, ) - assert fitter.minimizer.fit_calls[0]['finalize_tracking'] is False + assert fitter.minimizer.fit_calls[0]['options'].finalize_tracking is False assert fitter.minimizer.stop_calls == 1 - assert analysis_events == ['capture', 'store'] + assert analysis_events == ['capture', 'store', 'finalize', ('time', 12.5), 'stop'] def test_fitter_fit_stops_tracking_when_minimizer_fit_raises(monkeypatch): diff --git a/tests/unit/easydiffraction/analysis/test_sequential.py b/tests/unit/easydiffraction/analysis/test_sequential.py index 2be4c19f3..ac8932df7 100644 --- a/tests/unit/easydiffraction/analysis/test_sequential.py +++ b/tests/unit/easydiffraction/analysis/test_sequential.py @@ -576,6 +576,8 @@ def test_fit_sequential_non_silent_starts_indicator_with_progress_table( tmp_path, verbosity, ): + from easydiffraction.utils.utils import display_path + events = _run_non_silent_fit( monkeypatch, tmp_path, @@ -601,7 +603,11 @@ def test_fit_sequential_non_silent_starts_indicator_with_progress_table( assert events[8] == ('stop',) assert events[9:] == [ ('console_print', ('✅ Sequential fitting complete: 1 files processed.',), {}), - ('console_print', (f'📄 Results saved to:\n{tmp_path / "results.csv"}',), {}), + ( + 'console_print', + (f"📄 Results saved to '{display_path(tmp_path / 'results.csv')}'",), + {}, + ), ] diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py index 185a21f30..f2e808c5f 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py @@ -40,6 +40,112 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: assert ex.peak.type == 'cwl-pseudo-voigt' +def test_pd_experiment_peak_profile_switch_warning_lists_added_settings(monkeypatch): + from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType + from easydiffraction.datablocks.experiment.item import base as item_base + from easydiffraction.datablocks.experiment.item.base import PdExperimentBase + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + + class ConcretePd(PdExperimentBase): + def _load_ascii_data_to_experiment(self, data_path: str) -> int: + return 0 + + et = ExperimentType() + et._set_sample_form(SampleFormEnum.POWDER.value) + et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value) + et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value) + et._set_scattering_type(ScatteringTypeEnum.BRAGG.value) + + warnings: list[str] = [] + monkeypatch.setattr(item_base.log, 'warning', warnings.append) + ex = ConcretePd(name='ex1', type=et) + + ex.peak.type = 'pseudo-voigt + empirical asymmetry' + + assert warnings == [ + ( + 'Switching peak profile type adds these settings with defaults:\n' + '• asym_empir_1=0.0\n' + '• asym_empir_2=0.0\n' + '• asym_empir_3=0.0\n' + '• asym_empir_4=0.0' + ) + ] + + +def test_pd_experiment_peak_profile_switch_warning_lists_reset_settings(monkeypatch): + from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType + from easydiffraction.datablocks.experiment.item import base as item_base + from easydiffraction.datablocks.experiment.item.base import PdExperimentBase + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + + class ConcretePd(PdExperimentBase): + def _load_ascii_data_to_experiment(self, data_path: str) -> int: + return 0 + + et = ExperimentType() + et._set_sample_form(SampleFormEnum.POWDER.value) + et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value) + et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value) + et._set_scattering_type(ScatteringTypeEnum.BRAGG.value) + + warnings: list[str] = [] + monkeypatch.setattr(item_base.log, 'warning', warnings.append) + ex = ConcretePd(name='ex1', type=et) + ex.peak.broad_gauss_u = 0.05 + + ex.peak.type = 'pseudo-voigt + empirical asymmetry' + + assert warnings[1] == ( + 'Switching peak profile type resets these settings to defaults:\n' + '• broad_gauss_u: 0.05 -> 0.01' + ) + + +def test_pd_experiment_peak_profile_switch_warning_lists_removed_settings(monkeypatch): + from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType + from easydiffraction.datablocks.experiment.item import base as item_base + from easydiffraction.datablocks.experiment.item.base import PdExperimentBase + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + + class ConcretePd(PdExperimentBase): + def _load_ascii_data_to_experiment(self, data_path: str) -> int: + return 0 + + et = ExperimentType() + et._set_sample_form(SampleFormEnum.POWDER.value) + et._set_beam_mode(BeamModeEnum.CONSTANT_WAVELENGTH.value) + et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value) + et._set_scattering_type(ScatteringTypeEnum.BRAGG.value) + + warnings: list[str] = [] + monkeypatch.setattr(item_base.log, 'warning', warnings.append) + ex = ConcretePd(name='ex1', type=et) + ex.peak.type = 'pseudo-voigt + empirical asymmetry' + warnings.clear() + + ex.peak.type = 'pseudo-voigt' + + assert warnings == [ + ( + 'Switching peak profile type removes these settings:\n' + '• asym_empir_1\n' + '• asym_empir_2\n' + '• asym_empir_3\n' + '• asym_empir_4' + ) + ] + + def test_pd_experiment_set_peak_profile_type_silent(capsys): """_set_peak_profile_type switches the peak type without console output.""" from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType diff --git a/tests/unit/easydiffraction/io/test_results_sidecar.py b/tests/unit/easydiffraction/io/test_results_sidecar.py index 75dbe571e..ae95e9532 100644 --- a/tests/unit/easydiffraction/io/test_results_sidecar.py +++ b/tests/unit/easydiffraction/io/test_results_sidecar.py @@ -164,6 +164,32 @@ def test_write_analysis_results_sidecar_truncates_stale_payloads(tmp_path): assert 'hrpt' in handle['predictive'] +def test_write_analysis_results_sidecar_preserves_emcee_chain_group(tmp_path): + from easydiffraction.analysis.minimizers.emcee import EMCEE_CHAIN_GROUP + from easydiffraction.io import results_sidecar as results_sidecar_mod + + analysis_dir = Path(tmp_path) / 'analysis' + analysis = _analysis_with_sidecar_payload() + results_sidecar_mod.write_analysis_results_sidecar( + analysis=analysis, + analysis_dir=analysis_dir, + ) + + import h5py + + with h5py.File(analysis_dir / 'results.h5', 'a') as handle: + chain = handle.require_group(EMCEE_CHAIN_GROUP) + chain.attrs['iteration'] = 7 + + results_sidecar_mod.write_analysis_results_sidecar( + analysis=analysis, + analysis_dir=analysis_dir, + ) + + with h5py.File(analysis_dir / 'results.h5', 'r') as handle: + assert handle[EMCEE_CHAIN_GROUP].attrs['iteration'] == 7 + + def test_should_use_sidecar_compares_to_fit_result_kind_enum(): """`_should_use_sidecar` must read from `FitResultKindEnum`, not a literal.""" from easydiffraction.analysis.enums import FitResultKindEnum diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py index 3fb165574..87f2d71b3 100644 --- a/tests/unit/easydiffraction/utils/test_utils.py +++ b/tests/unit/easydiffraction/utils/test_utils.py @@ -13,6 +13,14 @@ def test_module_import(): assert expected_module_name == actual_module_name +def test_format_bulleted_warning(): + import easydiffraction.utils.utils as MUT + + warning = MUT.format_bulleted_warning('Header:', ['first', 'second']) + + assert warning == 'Header:\n• first\n• second' + + def test_twotheta_to_d_scalar_and_array(): import easydiffraction.utils.utils as MUT @@ -306,6 +314,39 @@ def __exit__(self, *args): assert (tmp_path / 'ed-1.ipynb').exists() +def test_download_tutorial_uses_artifact_root(monkeypatch, tmp_path): + import easydiffraction.utils.utils as MUT + + fake_index = { + '1': { + 'url': 'https://example.com/{version}/tutorials/ed-1/ed-1.ipynb', + 'title': 'Quick Start', + }, + } + artifact_root = tmp_path / 'artifacts' + monkeypatch.setenv('EASYDIFFRACTION_ARTIFACT_ROOT', str(artifact_root)) + monkeypatch.setattr(MUT, '_fetch_tutorials_index', lambda: fake_index) + monkeypatch.setattr(MUT, '_get_version_for_url', lambda: '0.8.0') + + class DummyResp: + def read(self): + return b'{"cells": []}' + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + monkeypatch.setattr(MUT, '_safe_urlopen', lambda url: DummyResp()) + + result = MUT.download_tutorial(id=1, destination='tutorials') + + expected_path = artifact_root / 'tutorials' / 'ed-1.ipynb' + assert result == str(expected_path) + assert expected_path.exists() + + def test_download_tutorial_already_exists_no_overwrite(monkeypatch, tmp_path, capsys): import easydiffraction.utils.utils as MUT @@ -382,6 +423,46 @@ def __exit__(self, *args): assert (tmp_path / 'ed-2.ipynb').exists() +def test_download_all_tutorials_reports_resolved_artifact_root( + monkeypatch, + tmp_path, + capsys, +): + import easydiffraction.utils.utils as MUT + + fake_index = { + '1': { + 'url': 'https://example.com/{version}/tutorials/ed-1/ed-1.ipynb', + 'title': 'Quick Start', + }, + } + artifact_root = tmp_path / 'artifacts' + monkeypatch.setenv('EASYDIFFRACTION_ARTIFACT_ROOT', str(artifact_root)) + monkeypatch.setattr(MUT, '_fetch_tutorials_index', lambda: fake_index) + monkeypatch.setattr(MUT, '_get_version_for_url', lambda: '0.8.0') + + class DummyResp: + def read(self): + return b'{"cells": []}' + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + monkeypatch.setattr(MUT, '_safe_urlopen', lambda url: DummyResp()) + + result = MUT.download_all_tutorials(destination='tutorials') + + expected_dir = artifact_root / 'tutorials' + assert result == [str(expected_dir / 'ed-1.ipynb')] + assert (expected_dir / 'ed-1.ipynb').exists() + out = capsys.readouterr().out + assert 'Downloaded 1 tutorials' in out + assert 'artifacts/tutorials' in out + + def test_resolve_tutorial_url(): # Test with a specific version diff --git a/tests/unit/test_benchmark_tutorials.py b/tests/unit/test_benchmark_tutorials.py index c0b390ef7..9688bddda 100644 --- a/tests/unit/test_benchmark_tutorials.py +++ b/tests/unit/test_benchmark_tutorials.py @@ -36,7 +36,6 @@ def test_append_result_writes_one_row(tmp_path): tutorial_name='ed-21.py', elapsed_seconds=12.3456, status='ok', - return_code=0, ), ) @@ -48,7 +47,6 @@ def test_append_result_writes_one_row(tmp_path): 'tutorial_name': 'ed-21.py', 'elapsed_seconds': '12.346', 'status': 'ok', - 'return_code': '0', } ] @@ -80,8 +78,13 @@ def fake_run_tutorial( script_path: Path, tutorial_dir_path: Path, env: dict[str, str], + *, + index: int, + total: int, + name_width: int, + is_tty: bool, ) -> MUT.TutorialBenchmarkResult: - del tutorial_dir_path, env + del tutorial_dir_path, env, index, total, name_width, is_tty if script_path == first_tutorial: with output_path.open(encoding='utf-8', newline='') as handle: rows = list(csv.reader(handle)) @@ -90,7 +93,6 @@ def fake_run_tutorial( tutorial_name='ed-01.py', elapsed_seconds=1.0, status='ok', - return_code=0, ) with output_path.open(encoding='utf-8', newline='') as handle: @@ -100,14 +102,12 @@ def fake_run_tutorial( 'tutorial_name': 'ed-01.py', 'elapsed_seconds': '1.000', 'status': 'ok', - 'return_code': '0', } ] return MUT.TutorialBenchmarkResult( tutorial_name='ed-02.py', elapsed_seconds=2.0, status='ok', - return_code=0, ) monkeypatch.setattr(MUT, '_run_tutorial', fake_run_tutorial) @@ -122,12 +122,10 @@ def fake_run_tutorial( 'tutorial_name': 'ed-01.py', 'elapsed_seconds': '1.000', 'status': 'ok', - 'return_code': '0', }, { 'tutorial_name': 'ed-02.py', 'elapsed_seconds': '2.000', 'status': 'ok', - 'return_code': '0', }, ] diff --git a/tools/benchmark_tutorials.py b/tools/benchmark_tutorials.py index e28d77e98..a43932b4a 100644 --- a/tools/benchmark_tutorials.py +++ b/tools/benchmark_tutorials.py @@ -6,20 +6,30 @@ import csv import os import platform +import re import subprocess # noqa: S404 import sys +import tempfile import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from pathlib import PurePosixPath +SECONDS_PER_MINUTE = 60 + ROOT = Path(__file__).resolve().parents[1] SRC_ROOT = ROOT / 'src' DEFAULT_TUTORIAL_DIR = ROOT / 'docs' / 'docs' / 'tutorials' DEFAULT_OUTPUT_DIR = ROOT / 'docs' / 'dev' / 'benchmarking' CHECKPOINT_DIR_NAME = '.ipynb_checkpoints' -CSV_HEADER = ['tutorial_name', 'elapsed_seconds', 'status', 'return_code'] +CSV_HEADER = ['tutorial_name', 'elapsed_seconds', 'status'] + +# Layout for the live single-line table. The name column is sized +# from the longest tutorial name encountered. +STATUS_COLUMN_WIDTH = 10 +TIME_COLUMN_WIDTH = 10 # includes trailing 's' +PROGRESS_POLL_SECONDS = 0.2 @dataclass(frozen=True) @@ -29,7 +39,6 @@ class TutorialBenchmarkResult: tutorial_name: str elapsed_seconds: float status: str - return_code: int def _relative_display_path(path: Path, start_path: Path) -> str: @@ -55,14 +64,58 @@ def _build_env() -> dict[str, str]: return env +def _natural_sort_key(path: Path) -> list[object]: + """ + Return a sort key for natural ordering. + + Splits the path string into alternating text and integer tokens + so ``ed-1.py``, ``ed-2.py``, ``ed-10.py`` sort numerically + instead of lexicographically. + """ + return [ + int(token) if token.isdigit() else token.lower() + for token in re.split(r'(\d+)', str(path)) + ] + + def _discover_tutorials(tutorial_dir: Path) -> list[Path]: return [ path - for path in sorted(tutorial_dir.rglob('*.py')) + for path in sorted(tutorial_dir.rglob('*.py'), key=_natural_sort_key) if CHECKPOINT_DIR_NAME not in path.parts ] +def _format_elapsed(seconds: float) -> str: + """ + Format elapsed time for table display. + + Rounds to the nearest whole second. Values that round to less + than one minute render as ``Xs`` (e.g. ``20s``); values that + round to a minute or more render as ``Xm Ys`` (e.g. ``1m 18s``, + ``20m 1s``). + """ + total_seconds = int(round(seconds)) + if total_seconds < SECONDS_PER_MINUTE: + return f'{total_seconds}s' + minutes, remaining_seconds = divmod(total_seconds, SECONDS_PER_MINUTE) + return f'{minutes}m {remaining_seconds:>2}s' + + +def _format_counter(index: int | None, total: int) -> str: + """ + Format the ``[n/N]`` counter cell for the progress table. + + Returns a blank string of the same width when *index* is + ``None`` so the total-time summary row aligns with the data + rows. + """ + width = len(str(total)) + if index is None: + return ' ' * (width * 2 + 3) # length of '[n/N]' + return f'[{index:>{width}}/{total}]' + + def _matches_requested_patterns( script_path: Path, tutorial_dir: Path, @@ -75,37 +128,112 @@ def _matches_requested_patterns( return any(rel_path.match(pattern) or script_path.name == pattern for pattern in patterns) +def _format_progress_line( + *, + counter: str, + name: str, + name_width: int, + status: str, + elapsed_text: str, +) -> str: + """Format one row for the live progress table.""" + return ( + f'{counter} ' + f'{name:<{name_width}} ' + f'{status:<{STATUS_COLUMN_WIDTH}} ' + f'{elapsed_text:>{TIME_COLUMN_WIDTH}}' + ) + + +def _emit_progress_line(*, line: str, final: bool, is_tty: bool) -> None: + """ + Render one progress line. + + On a TTY the same line is rewritten in place via carriage return so + the elapsed-seconds field updates while the tutorial is running; + only the final write terminates with a newline. When stdout is not + a TTY (CI, log file), only the final row is printed so the log + stays one-line-per-tutorial without carriage-return noise. + """ + if is_tty: + terminator = '\n' if final else '' + sys.stdout.write('\r' + line + terminator) + sys.stdout.flush() + elif final: + print(line) + + def _run_tutorial( script_path: Path, tutorial_dir: Path, env: dict[str, str], + *, + index: int, + total: int, + name_width: int, + is_tty: bool, ) -> TutorialBenchmarkResult: + """Run one tutorial with a live single-line progress indicator.""" tutorial_name = _relative_display_path(script_path, tutorial_dir) start_time = time.perf_counter() - result = subprocess.run( # noqa: S603 - [sys.executable, str(script_path)], - cwd=str(ROOT), - env=env, - capture_output=True, - text=True, - encoding='utf-8', - ) - elapsed_seconds = time.perf_counter() - start_time - status = 'ok' if result.returncode == 0 else 'failed' - if result.returncode == 0: - print(f' OK {elapsed_seconds:.1f}s') - else: - print(f' FAILED {elapsed_seconds:.1f}s', file=sys.stderr) - details = ((result.stdout or '') + (result.stderr or '')).strip() - if details: - print(details, file=sys.stderr) + # Combined stdout+stderr land in a temp file so the OS pipe buffer + # cannot fill up and stall the subprocess while we poll. We only + # read the contents back if the tutorial fails. + with tempfile.TemporaryFile(mode='wb+') as out_file: + proc = subprocess.Popen( # noqa: S603 + [sys.executable, str(script_path)], + cwd=str(ROOT), + env=env, + stdout=out_file, + stderr=subprocess.STDOUT, + ) + + counter = _format_counter(index, total) + last_displayed_second = -1 + while proc.poll() is None: + elapsed = time.perf_counter() - start_time + current_second = int(elapsed) + if current_second != last_displayed_second: + _emit_progress_line( + line=_format_progress_line( + counter=counter, + name=tutorial_name, + name_width=name_width, + status='Running...', + elapsed_text=_format_elapsed(elapsed), + ), + final=False, + is_tty=is_tty, + ) + last_displayed_second = current_second + time.sleep(PROGRESS_POLL_SECONDS) + + elapsed_seconds = time.perf_counter() - start_time + success = proc.returncode == 0 + status_word = 'OK' if success else 'FAILED' + _emit_progress_line( + line=_format_progress_line( + counter=counter, + name=tutorial_name, + name_width=name_width, + status=status_word, + elapsed_text=_format_elapsed(elapsed_seconds), + ), + final=True, + is_tty=is_tty, + ) + + if not success: + out_file.seek(0) + details = out_file.read().decode('utf-8', errors='replace').strip() + if details: + print(details, file=sys.stderr) return TutorialBenchmarkResult( tutorial_name=tutorial_name, elapsed_seconds=elapsed_seconds, - status=status, - return_code=result.returncode, + status='ok' if success else 'failed', ) @@ -136,7 +264,6 @@ def _append_result(output_path: Path, result: TutorialBenchmarkResult) -> None: result.tutorial_name, f'{result.elapsed_seconds:.3f}', result.status, - result.return_code, ] ) @@ -191,19 +318,38 @@ def main() -> int: _write_csv_header(output_path) env = _build_env() + is_tty = sys.stdout.isatty() + name_width = max( + len(_relative_display_path(path, tutorial_dir)) for path in tutorials + ) + results: list[TutorialBenchmarkResult] = [] for index, tutorial_path in enumerate(tutorials, start=1): - tutorial_name = _relative_display_path(tutorial_path, tutorial_dir) - print(f'[{index:2}/{len(tutorials)}] Running {tutorial_name}') - result = _run_tutorial(tutorial_path, tutorial_dir, env) + result = _run_tutorial( + tutorial_path, + tutorial_dir, + env, + index=index, + total=len(tutorials), + name_width=name_width, + is_tty=is_tty, + ) results.append(result) _append_result(output_path, result) total_elapsed = sum(result.elapsed_seconds for result in results) failure_count = sum(result.status == 'failed' for result in results) - print(f'Wrote benchmark results to {_relative_display_path(output_path, ROOT)}') - print(f'Total elapsed time: {total_elapsed:.3f}s') + print( + _format_progress_line( + counter=_format_counter(None, len(tutorials)), + name='Total', + name_width=name_width, + status='', + elapsed_text=_format_elapsed(total_elapsed), + ) + ) + print(f'Save results to: {_relative_display_path(output_path, ROOT)}') if failure_count: print(f'Failed tutorials: {failure_count}', file=sys.stderr) @@ -213,4 +359,4 @@ def main() -> int: if __name__ == '__main__': - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 5a6fea21ba1edde1c4ddaf1bdcad02175da774c7 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 26 May 2026 08:40:44 +0200 Subject: [PATCH 04/12] Add undo-fit rollback to Analysis and CLI (#183) * Add tutorial benchmark results * Add undo-fit ADR suggestion * Split fit-state save gate from _fit_parameter rows * Use _fit_result.result_kind as the fit-state load marker * Decouple live-bound restoration from fit-result flag * Add Analysis.undo_fit() rollback operation * Wire CLI undo to Analysis.undo_fit() * Promote undo-fit ADR to accepted * Reach Phase 1 review gate * Re-export undo-fit outcome type * Align undo CLI example with ASCII output * Clarify undo rollback start-value filter * Apply pixi run fix auto-fixes * Document undo outcome attributes * Add undo-fit verification coverage * Assert all undo posterior fields clear * Make tutorial artifact-root assertion path portable * Document undo command in user-facing docs * Interpret empty parentheses as no esd * Clarify empty parentheses comment * Remove undo-fit implementation plan --- docs/dev/adrs/accepted/undo-fit.md | 320 ++++++++++++++++++ docs/dev/adrs/index.md | 2 +- docs/dev/adrs/suggestions/undo-fit.md | 126 ------- ...darwin-arm64_py314_tutorial-benchmarks.csv | 26 ++ docs/dev/package-structure/full.md | 1 + docs/docs/cli/index.md | 23 +- docs/docs/quick-reference/index.md | 3 + src/easydiffraction/__main__.py | 54 ++- src/easydiffraction/analysis/__init__.py | 1 + src/easydiffraction/analysis/analysis.py | 176 +++++++++- src/easydiffraction/io/cif/serialize.py | 29 +- src/easydiffraction/project/project.py | 5 +- src/easydiffraction/utils/utils.py | 8 +- .../easydiffraction/analysis/test_analysis.py | 191 +++++++++++ .../easydiffraction/io/cif/test_serialize.py | 55 +++ .../easydiffraction/project/test_project.py | 56 +++ tests/unit/easydiffraction/test___main__.py | 110 ++++++ .../unit/easydiffraction/utils/test_utils.py | 3 +- .../utils/test_utils_coverage.py | 11 +- 19 files changed, 1037 insertions(+), 163 deletions(-) create mode 100644 docs/dev/adrs/accepted/undo-fit.md delete mode 100644 docs/dev/adrs/suggestions/undo-fit.md create mode 100644 docs/dev/benchmarking/20260526-000646_darwin-arm64_py314_tutorial-benchmarks.csv diff --git a/docs/dev/adrs/accepted/undo-fit.md b/docs/dev/adrs/accepted/undo-fit.md new file mode 100644 index 000000000..78816d0d8 --- /dev/null +++ b/docs/dev/adrs/accepted/undo-fit.md @@ -0,0 +1,320 @@ +# ADR: Undo Fit + +**Status:** Accepted +**Date:** 2026-05-18 + +## Context + +The accepted fit-state persistence design now stores +`_fit_parameter.start_value` and `_fit_parameter.start_uncertainty` in +`analysis/analysis.cif`. Those fields capture the last committed pre-fit +scalar state for each fitted parameter and are the essential rollback +anchors for any undo feature. + +This branch also introduced project-first CLI routing and reserved a +top-level `undo` command shape, but the command is still only a +placeholder. The actual rollback semantics are still undecided. + +Parameter-level posterior summaries are now first-class on the live +`Parameter` object (see +[`minimizer-category-consolidation.md`](../accepted/minimizer-category-consolidation.md) +and the `_posterior` slot on `core.variable.Parameter`). Undo must clear +that summary for every fitted parameter. Saved projects that predate the +posterior slot persist no posterior data, so clearing it is a silent +no-op for them — undo does not depend on any specific +posterior-persistence schema. + +## Decision + +### 1. Add an analysis-owned `undo_fit()` operation + +The rollback operation belongs on `Analysis`: + +```python +project.analysis.undo_fit() +``` + +`Analysis` owns fit execution, fit metadata, and the persisted fit-state +projection, so it is the correct public owner. + +### 2. Initial undo scope is scalar rollback plus fit-state clear + +The first undo implementation restores each fitted parameter's saved +pre-fit scalar state and clears fit-derived state that belongs only to +the discarded fit. + +After `undo_fit()`: + +- `parameter.value` is restored from `_fit_parameter.start_value` +- `parameter.uncertainty` is restored from + `_fit_parameter.start_uncertainty` +- `parameter.posterior` is cleared on every fitted parameter (was + populated by Bayesian fits; deterministic fits already carry `None`, + so undo is a no-op for that field there) +- `analysis.fit_results` is cleared +- `_fit_result.*` is cleared — purely fit-derived (R-factors, + goodness-of-fit, iteration counts, …); no user-owned data lives here +- `_fit_parameter_correlations` is cleared — purely fit-derived +- `_fit_parameter` rows are **preserved**. The collection carries both + user-owned fit controls (`fit_min`, `fit_max`, + `fit_bounds_uncertainty_multiplier`) and the rollback anchors + themselves (`start_value`, `start_uncertainty`). Clearing the whole + collection — which is what `Analysis._clear_persisted_fit_state()` + does at the start of a new fit — would silently drop the user's bounds + and erase the anchors needed for idempotence (§6). Undo therefore + leaves these rows in place; the next fit rewrites them via + `_capture_fit_parameter_state()`. +- `analysis/results.h5` is cleared in memory only: the + `Analysis._persisted_fit_state_sidecar` dict is reset to empty. All + canonical groups (`/posterior`, `/distribution_cache`, `/pair_cache`, + `/predictive`, plus `/emcee_chain` for emcee fits) belong to the + discarded fit, so the next save writes an empty sidecar and truncates + the file. This is the same truncation that runs at the start of a new + fit — see + [`minimizer-category-consolidation.md`](../accepted/minimizer-category-consolidation.md) + §4. + +The sequential history file `analysis/results.csv` is **not** touched by +undo. Sequential fits record one row per swept row, accumulated across +many fits; "the most recent fit" has no unique row to roll back. +Sequential rollback is deferred. + +**Disk side-effects.** `undo_fit()` mutates in-memory state only — +parameter values and uncertainties, `analysis.fit_results`, the +posterior summary on each fitted `Parameter`, the persisted-fit-state +result categories, and the `_persisted_fit_state_sidecar` dict. No CIF +file or HDF5 sidecar is rewritten until `project.save()` runs. This +separation is what makes the CLI `--dry` flag (§5) implementable via the +same public operation: call `undo_fit()`, skip the save. + +**Persistence and load contract.** Preserving `_fit_parameter` rows in +memory is not enough on its own: today the analysis CIF save side gates +all three persisted-fit-state categories (`_fit_parameter`, +`_fit_result`, `_fit_parameter_correlations`) together on +`Analysis._has_persisted_fit_state()` +(`src/easydiffraction/analysis/analysis.py` around lines 1025-1027 and +1482-1500), and the load side uses any one of `_fit_result.result_kind`, +the `_fit_parameter` loop, or the `_fit_parameter_correlation` loop as +the "fit-state is present" marker +(`src/easydiffraction/io/cif/serialize.py` +`_has_persisted_fit_state_sections` around lines 580-590). Under that +coupling, an undo+save+reload cycle would re-trigger +`_restore_persisted_fit_state()` because the preserved `_fit_parameter` +rows still satisfy the loop marker, the lazy `fit_results` rebuild path +at `src/easydiffraction/analysis/analysis.py` lines 421-425 would +fabricate a stale or empty `FitResults`, and idempotent no-op detection +(§6) would never fire because `analysis.fit_results` would be non-`None` +again. + +The persistence layer must therefore split this single gate into two +independent ones: + +- **`_fit_parameter` rows** carry user-owned bounds and rollback + anchors. They are written to `analysis/analysis.cif` whenever any rows + exist, and they are read back on load whenever the loop is present in + the CIF — both **independent of** any fit-result presence flag. +- **`_fit_result.*` and `_fit_parameter_correlations`** describe a + committed fit-result. They are written only when a fit-result is + currently present (after a successful fit and before any undo) and + read back only when `_fit_result.result_kind` is present in the CIF. +- **`_fit_result.result_kind`** is the canonical "fit-result is present" + marker on disk. The `_fit_parameter` loop and the + `_fit_parameter_correlation` loop no longer count toward that + decision. `Analysis._has_persisted_fit_state()` is set true on load + iff `_fit_result.result_kind` is present in the CIF. + +After `undo_fit()` + `project.save()`, the saved CIF therefore carries +`_fit_parameter` rows (bounds and anchors), no `_fit_result.*`, and no +`_fit_parameter_correlation` rows. After loading that project, +`Analysis._has_persisted_fit_state()` returns `False`, the lazy +`fit_results` rebuild path does not fire, and `analysis.fit_results` +returns `None`. A subsequent `undo_fit()` call lands in the no-op branch +of §6 because every fitted parameter is already at its saved +`start_value`. Idempotence therefore survives the save+reload cycle, not +just within a single session. + +If an older saved project lacks `start_uncertainty`, clearing +`parameter.uncertainty` remains an acceptable compatibility fallback. + +### 3. Undo does not roll back user configuration + +The initial undo operation does not revert: + +- aliases +- constraints +- fit bounds +- minimizer type +- fit mode +- joint-fit weights + +These belong to analysis configuration, not fit output. + +### 4. Undo is single-level for now + +Only the latest saved pre-fit snapshot is addressable. Multi-level undo +and redo require a dedicated snapshot-history design and remain +deferred. + +### 5. CLI exposure follows the project-first command style + +The command-line surface should follow the current CLI style: + +```bash +python -m easydiffraction PROJECT_DIR undo +``` + +This command should: + +- load the saved project from `PROJECT_DIR` +- execute `project.analysis.undo_fit()` +- save the recovered state back to the same project directory by default + — the rewritten `analysis/analysis.cif` reflects the rolled-back + scalars and `analysis/results.h5` is truncated +- support `--dry` to preview the rollback without writing any file. The + in-memory rollback still runs (so the summary numbers are real), but + `project.save()` is skipped. This mirrors the existing + `easydiffraction PROJECT_DIR fit --dry` semantics. +- exit cleanly (status 0) in every no-op case enumerated in §6 — a + project that has nothing to undo is not an error condition, whether it + has never been fit, was already undone, or predates the start-value + persistence schema + +Compatibility aliases may remain if the CLI supports them, but the +project-first form is the canonical user-facing syntax. + +### 6. Error paths and idempotence + +`undo_fit()` is safe to call when nothing is left to undo. The operation +never raises for "nothing to undo" — every absence-of-data case +collapses into the same no-op branch so that scripts can call `undo` +unconditionally: + +- If `analysis.fit_results` is `None` **and** every fitted parameter is + already at its saved `_fit_parameter.start_value` (within float + tolerance), `undo_fit()` is a clean no-op. It returns without mutating + anything and emits a short `"No fit to undo."` message at INFO level. +- If saved `_fit_parameter.start_value` rows are present but the current + `Parameter.value` differs, `undo_fit()` performs the rollback per §2 + even when `analysis.fit_results` happens to be `None` — the persisted + snapshot is the source of truth, not the in-memory result object. +- If no `_fit_parameter.start_value` row exists at all — either because + the project has never been fit, or because it is a legacy project that + predates start-value persistence — `undo_fit()` is also a clean no-op. + There is nothing fitted to roll back, the live `Parameter` state is + untouched, and the same `"No fit to undo."` INFO message is emitted. + The Python operation does not raise and the CLI does not exit non-zero + in this case (§5). +- If a saved project predates `_fit_parameter.start_uncertainty` (older + fit-state schema but `start_value` is present), the + uncertainty-clearing fallback from §2 applies; `parameter.uncertainty` + is set to `None` and the fallback is logged once at INFO level. + +Calling `undo_fit()` twice in a row is therefore safe: the second call +finds parameters already at their start values and `fit_results` already +cleared, and exits cleanly via the first no-op branch. The same applies +across save+reload cycles — the persistence and load contract in §2 +ensures that an undone project, once reloaded, still presents +`analysis.fit_results == None` and preserved +`_fit_parameter.start_value` rows, so the second undo remains a clean +no-op rather than a fresh rollback. Scripted workflows that call `undo` +on every saved project regardless of fit history are also safe: undo is +always either a rollback or a no-op, never an error. + +## Examples + +### Python API + +```python +import easydiffraction as ed + +project = ed.Project.load('projects/lbco_hrpt') + +# After a fit has been committed, the project carries refined state: +project.analysis.fit_results # FitResults(success=True, ...) +project.structures['lbco'].cell.length_a.value # 3.8913 +project.structures['lbco'].cell.length_a.uncertainty # 0.0001 + +# Roll back to the last saved pre-fit state: +project.analysis.undo_fit() + +# Scalar state is now restored; the result object is gone: +project.analysis.fit_results # None +project.structures['lbco'].cell.length_a.value # 3.8800 (start_value) +project.structures['lbco'].cell.length_a.uncertainty # 0.0000 (start_uncertainty) + +# Persist the rollback to disk; analysis/results.h5 is cleared too: +project.save() +``` + +For Bayesian fits, the same call also clears `parameter.posterior` on +every fitted parameter and truncates `analysis/results.h5` (the +`/posterior`, `/distribution_cache`, `/pair_cache`, `/predictive`, and +`/emcee_chain` groups). + +### CLI + +Standard invocation — loads the project, undoes the last fit, and saves +the rolled-back state back to the same directory: + +``` +$ python -m easydiffraction projects/lbco_hrpt undo +Undoing last fit for 'lbco_hrpt'... +✅ Restored 8 parameters to their pre-fit values. +✅ Cleared analysis.fit_results. +✅ Cleared analysis/results.h5 (Bayesian sidecar). +✅ Saved project to projects/lbco_hrpt. +``` + +Dry-run preview — prints the same summary without writing anything: + +``` +$ python -m easydiffraction projects/lbco_hrpt undo --dry +Would undo last fit for 'lbco_hrpt' (dry run, no files written): + - 8 parameters would be restored to pre-fit values + - analysis.fit_results would be cleared + - analysis/results.h5 (Bayesian sidecar) would be cleared +``` + +No-op cases — the project has nothing to undo. All three sub-cases exit +cleanly (status 0) with the same single-line message: (a) no fit has +ever been run on the project, (b) undo was already called and persisted, +or (c) the project is a legacy project that predates +`_fit_parameter.start_value` persistence and therefore has no saved +anchors at all: + +``` +$ python -m easydiffraction projects/lbco_hrpt undo +No fit to undo for 'lbco_hrpt'. Project state is unchanged. +$ echo $? +0 +``` + +## Consequences + +### Positive + +- The accepted fit-state persistence already provides the minimum saved + anchors required for cross-session undo. +- Users gain a predictable recovery path after a poor fit without + needing full historical fit snapshots. +- The feature aligns naturally with saved-project workflows in both + Python and the CLI. + +### Trade-offs + +- Undo restores visible scalar parameter state, not a full historical + runtime result object. +- Older saved projects may still need the uncertainty-clearing fallback. +- Multi-level undo remains unsupported. + +## Deferred Work + +- exact restoration of previous posterior-derived displays beyond the + scalar rollback anchors (the `parameter.posterior` summary is cleared + by undo but not _restored_ to a prior posterior state) +- multi-level undo and redo (single-level only for now per §4) +- sequential-fit row-level rollback (each row in `analysis/results.csv` + is appended over time; "the most recent fit" has no unique row to + address) +- confirmation or preview UX beyond `--dry` (no interactive prompt, no + diff view of which parameters change) diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index c235042db..f8b3763b2 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -23,7 +23,7 @@ folders. | Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | | Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | | Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | -| Analysis and fitting | Suggestion | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](suggestions/undo-fit.md) | +| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) | | Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | | Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | | Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | diff --git a/docs/dev/adrs/suggestions/undo-fit.md b/docs/dev/adrs/suggestions/undo-fit.md deleted file mode 100644 index cd658aba9..000000000 --- a/docs/dev/adrs/suggestions/undo-fit.md +++ /dev/null @@ -1,126 +0,0 @@ -# ADR: Undo Fit - -**Status:** Proposed -**Date:** 2026-05-18 - -## Status Note - -The rollback anchors described here are already persisted and restored. -Current code saves `_fit_parameter.start_value` and -`_fit_parameter.start_uncertainty` in `analysis/analysis.cif`, and the -CLI already reserves `PROJECT_DIR undo`, but no rollback operation is -implemented yet. - -## Context - -The accepted fit-state persistence design now stores -`_fit_parameter.start_value` and `_fit_parameter.start_uncertainty` in -`analysis/analysis.cif`. Those fields capture the last committed pre-fit -scalar state for each fitted parameter and are the essential rollback -anchors for any undo feature. - -This branch also introduced project-first CLI routing and reserved a -top-level `undo` command shape, but the command is still only a -placeholder. The actual rollback semantics are still undecided. - -Parameter-level posterior access remains a separate proposal. Undo must -not depend on `parameter.posterior` existing. - -## Decision - -### 1. Add an analysis-owned `undo_fit()` operation - -The rollback operation belongs on `Analysis`: - -```python -project.analysis.undo_fit() -``` - -`Analysis` owns fit execution, fit metadata, and the persisted fit-state -projection, so it is the correct public owner. - -### 2. Initial undo scope is scalar rollback plus fit-state clear - -The first undo implementation restores each fitted parameter's saved -pre-fit scalar state and clears fit-derived state that belongs only to -the discarded fit. - -After `undo_fit()`: - -- `parameter.value` is restored from `_fit_parameter.start_value` -- `parameter.uncertainty` is restored from - `_fit_parameter.start_uncertainty` -- `analysis.fit_results` is cleared -- persisted fit-state summaries and Bayesian caches for the discarded - fit are cleared - -If a future `parameter.posterior` API exists, undo should clear that -projection too. It is not a prerequisite for the initial implementation. - -If an older saved project lacks `start_uncertainty`, clearing -`parameter.uncertainty` remains an acceptable compatibility fallback. - -### 3. Undo does not roll back user configuration - -The initial undo operation does not revert: - -- aliases -- constraints -- fit bounds -- minimizer type -- fit mode -- joint-fit weights - -These belong to analysis configuration, not fit output. - -### 4. Undo is single-level for now - -Only the latest saved pre-fit snapshot is addressable. Multi-level undo -and redo require a dedicated snapshot-history design and remain -deferred. - -### 5. CLI exposure follows the project-first command style - -The command-line surface should follow the current CLI style: - -```bash -python -m easydiffraction PROJECT_DIR undo -``` - -This command should: - -- load the saved project from `PROJECT_DIR` -- execute `project.analysis.undo_fit()` -- save the recovered state back to the same project directory by default -- support `--dry` to preview the rollback without overwriting files -- fail with a clear non-zero exit status when no usable undo snapshot is - available - -Compatibility aliases may remain if the CLI supports them, but the -project-first form is the canonical user-facing syntax. - -## Consequences - -### Positive - -- The accepted fit-state persistence already provides the minimum saved - anchors required for cross-session undo. -- Users gain a predictable recovery path after a poor fit without - needing full historical fit snapshots. -- The feature aligns naturally with saved-project workflows in both - Python and the CLI. - -### Trade-offs - -- Undo restores visible scalar parameter state, not a full historical - runtime result object. -- Older saved projects may still need the uncertainty-clearing fallback. -- Multi-level undo remains unsupported. - -## Deferred Work - -- exact restoration of previous posterior-derived displays beyond the - scalar rollback anchors -- multi-level undo and redo -- confirmation or preview UX beyond `--dry` -- any dependency on a future `parameter.posterior` API diff --git a/docs/dev/benchmarking/20260526-000646_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260526-000646_darwin-arm64_py314_tutorial-benchmarks.csv new file mode 100644 index 000000000..204052642 --- /dev/null +++ b/docs/dev/benchmarking/20260526-000646_darwin-arm64_py314_tutorial-benchmarks.csv @@ -0,0 +1,26 @@ +tutorial_name,elapsed_seconds,status +ed-1.py,21.004,ok +ed-2.py,24.851,ok +ed-3.py,27.121,ok +ed-4.py,5.900,ok +ed-5.py,54.656,ok +ed-6.py,86.646,ok +ed-7.py,157.572,ok +ed-8.py,145.281,ok +ed-9.py,11.428,ok +ed-10.py,48.895,ok +ed-11.py,13.226,ok +ed-12.py,11.201,ok +ed-13.py,30.966,ok +ed-14.py,8.752,ok +ed-15.py,34.648,ok +ed-16.py,74.154,ok +ed-17.py,107.271,ok +ed-18.py,8.176,ok +ed-20.py,46.544,ok +ed-21.py,90.724,ok +ed-22.py,44.876,ok +ed-23.py,26.125,ok +ed-24.py,5.508,ok +ed-25.py,31.829,ok +ed-26.py,33.452,ok diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index f39fef485..9764d5fa1 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -169,6 +169,7 @@ │ │ └── 🏷️ class LmfitLeastsqMinimizer │ ├── 📄 __init__.py │ ├── 📄 analysis.py +│ │ ├── 🏷️ class UndoFitOutcome │ │ ├── 🏷️ class AnalysisDisplay │ │ ├── 🏷️ class _AnalysisOwnerAccessorsMixin │ │ ├── 🏷️ class _AnalysisPersistedCategoryAccessorsMixin diff --git a/docs/docs/cli/index.md b/docs/docs/cli/index.md index 46c283879..7e260284b 100644 --- a/docs/docs/cli/index.md +++ b/docs/docs/cli/index.md @@ -150,10 +150,29 @@ when the active chart engine is Plotly. ### Undo the Last Fit -The CLI already reserves the project-first undo command shape: +Roll back the most recent fit to the parameter values and uncertainties +captured just before it started: ```bash python -m easydiffraction PROJECT_DIR undo ``` -This command currently reports that undo support is not implemented yet. +The command restores each refined parameter to its saved pre-fit +`start_value` / `start_uncertainty`, clears `analysis.fit_results`, +truncates `analysis/results.h5` (the Bayesian sidecar), and **saves the +rolled-back state back** to the project directory by default. + +Use the `--dry` flag to preview the rollback **without overwriting** any +file: + +```bash +python -m easydiffraction PROJECT_DIR undo --dry +``` + +Undo is single-level: only the most recently committed fit is +addressable. Calling `undo` a second time, or running it on a project +that has never been fit, prints +`No fit to undo for ''. Project state is unchanged.` and exits +cleanly (status 0). Fit bounds, aliases, constraints, the minimizer +choice, the fit mode, and joint-fit weights are **not** reverted by undo +— only fit output is rolled back. diff --git a/docs/docs/quick-reference/index.md b/docs/docs/quick-reference/index.md index 9c4d715bd..868969772 100644 --- a/docs/docs/quick-reference/index.md +++ b/docs/docs/quick-reference/index.md @@ -406,6 +406,8 @@ Run a saved project from the command line: python -m easydiffraction lbco_hrpt fit python -m easydiffraction lbco_hrpt fit --dry python -m easydiffraction lbco_hrpt display +python -m easydiffraction lbco_hrpt undo +python -m easydiffraction lbco_hrpt undo --dry ``` Load a saved example project straight from `download_data()`: @@ -427,4 +429,5 @@ python -m easydiffraction download-tutorial 1 --destination tutorials python -m easydiffraction download-all-tutorials --destination tutorials python -m easydiffraction PROJECT_DIR fit python -m easydiffraction PROJECT_DIR display +python -m easydiffraction PROJECT_DIR undo ``` diff --git a/src/easydiffraction/__main__.py b/src/easydiffraction/__main__.py index 7f87520e0..201183c01 100644 --- a/src/easydiffraction/__main__.py +++ b/src/easydiffraction/__main__.py @@ -94,6 +94,46 @@ def _display_project_outputs(project: object) -> None: _display_project_patterns(project) +def _project_name(project: object, fallback: str) -> str: + """Return a display name for one project.""" + name = getattr(project, 'name', None) + return str(name) if name else fallback + + +def _display_undo_summary( + *, + project: object, + project_dir: str, + dry: bool, +) -> None: + """Run undo and render its command-line summary.""" + project_name = _project_name(project, project_dir) + outcome = project.analysis.undo_fit() + + if outcome.was_no_op: + typer.echo(f"No fit to undo for '{project_name}'. Project state is unchanged.") + return + + restored_count = len(outcome.restored_parameter_names) + if dry: + typer.echo(f"Would undo last fit for '{project_name}' (dry run, no files written):") + typer.echo(f' - {restored_count} parameters would be restored to pre-fit values') + if outcome.cleared_fit_result: + typer.echo(' - analysis.fit_results would be cleared') + if outcome.cleared_sidecar: + typer.echo(' - analysis/results.h5 (Bayesian sidecar) would be cleared') + return + + typer.echo(f"Undoing last fit for '{project_name}'...") + typer.echo(f'✅ Restored {restored_count} parameters to their pre-fit values.') + if outcome.cleared_fit_result: + typer.echo('✅ Cleared analysis.fit_results.') + if outcome.cleared_sidecar: + typer.echo('✅ Cleared analysis/results.h5 (Bayesian sidecar).') + project.save() + typer.echo(f'✅ Saved project to {project_dir}.') + + def run_cli(args: list[str] | None = None) -> None: """ Run the EasyDiffraction CLI with project-first argument support. @@ -233,13 +273,15 @@ def undo( ..., help='Path to the project directory (must contain project.cif).', ), + dry: bool = typer.Option( # noqa: FBT001 + False, # noqa: FBT003 + '--dry', + help='Undo fitting without saving results back to the project directory.', + ), ) -> None: - """ - Undo the last fit when fit-history support exists (not implemented). - """ - _load_project(project_dir) - typer.echo('Undo is not yet implemented.') - raise typer.Exit(code=1) + """Undo the last fit: easydiffraction PROJECT_DIR undo [--dry].""" + project = _load_project(project_dir) + _display_undo_summary(project=project, project_dir=project_dir, dry=dry) if __name__ == '__main__': diff --git a/src/easydiffraction/analysis/__init__.py b/src/easydiffraction/analysis/__init__.py index 325c9882a..12e8f82e4 100644 --- a/src/easydiffraction/analysis/__init__.py +++ b/src/easydiffraction/analysis/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from easydiffraction.analysis.analysis import UndoFitOutcome from easydiffraction.analysis.categories.fit_parameter_correlations import ( FitParameterCorrelationItem, ) diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 004fc6f9a..07724f1cb 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -4,7 +4,9 @@ from __future__ import annotations from contextlib import suppress +from dataclasses import dataclass from itertools import combinations +from math import isclose from pathlib import Path from typing import TYPE_CHECKING @@ -71,6 +73,31 @@ _POSTERIOR_SAMPLE_NDIM = 3 _FLATTENED_POSTERIOR_SAMPLE_NDIM = 2 _CREDIBLE_INTERVAL_LEVEL_COUNT = 2 +_UNDO_REL_TOL = 1e-12 +_UNDO_ABS_TOL = 0.0 + + +@dataclass(frozen=True) +class UndoFitOutcome: + """ + Summary of one undo-fit operation. + + Attributes + ---------- + restored_parameter_names : tuple[str, ...] + Unique names of parameters restored to pre-fit values. + cleared_fit_result : bool + Whether a committed fit-result projection was cleared. + cleared_sidecar : bool + Whether in-memory sidecar arrays were cleared. + was_no_op : bool + Whether the call found no fit to undo. + """ + + restored_parameter_names: tuple[str, ...] + cleared_fit_result: bool + cleared_sidecar: bool + was_no_op: bool # LSQ result descriptors default to ``None`` (review-8 F6); the CIF @@ -547,8 +574,11 @@ def _ordered_restored_parameter_names(self) -> list[str]: """ return [row.param_unique_name.value for row in self.fit_parameters] - def _restore_live_parameter_state(self, param_map: dict[str, Parameter]) -> None: - """Restore saved fit metadata onto live parameter objects.""" + def _restore_live_parameter_bounds_and_anchors( + self, + param_map: dict[str, Parameter], + ) -> None: + """Restore saved fit controls onto live parameter objects.""" for row in self.fit_parameters: parameter = param_map.get(row.param_unique_name.value) if parameter is None: @@ -565,11 +595,24 @@ def _restore_live_parameter_state(self, param_map: dict[str, Parameter]) -> None ) parameter._fit_start_value = row.start_value.value parameter._fit_start_uncertainty = row.start_uncertainty.value + + def _restore_live_parameter_posterior(self, param_map: dict[str, Parameter]) -> None: + """Restore saved posterior summaries onto live parameters.""" + for row in self.fit_parameters: + parameter = param_map.get(row.param_unique_name.value) + if parameter is None: + continue + posterior = row.posterior_summary(display_name=parameter.name) parameter._set_posterior(posterior) if posterior is not None and np.isfinite(posterior.standard_deviation): parameter.uncertainty = posterior.standard_deviation + def _restore_live_parameter_state(self, param_map: dict[str, Parameter]) -> None: + """Restore saved fit metadata onto live parameter objects.""" + self._restore_live_parameter_bounds_and_anchors(param_map) + self._restore_live_parameter_posterior(param_map) + def _restored_fit_parameters(self, param_map: dict[str, Parameter]) -> list[Parameter]: """Return live parameters in the persisted fit-result order.""" restored_parameters: list[Parameter] = [] @@ -1022,8 +1065,9 @@ def _serializable_categories(self) -> list: self.sequential_fit_extract, ]) + categories.extend(self._fit_parameter_state_categories()) if self._has_persisted_fit_state(): - categories.extend(self._fit_state_categories()) + categories.extend(self._fit_result_state_categories()) return categories @@ -1109,6 +1153,119 @@ def fit( except KeyboardInterrupt: self._handle_fit_interrupted(verbosity=verb) + def undo_fit(self) -> UndoFitOutcome: + """ + Roll back the latest fit output and scalar state. + + Returns + ------- + UndoFitOutcome + Summary of the rollback operation. + """ + if self._undo_is_noop(): + log.info('No fit to undo.') + return UndoFitOutcome( + restored_parameter_names=(), + cleared_fit_result=False, + cleared_sidecar=False, + was_no_op=True, + ) + + cleared_fit_result = self._has_persisted_fit_state() + restored_names = self._undo_scalar_rollback() + self._undo_clear_per_row_posterior_fields() + cleared_sidecar = bool(self._persisted_fit_state_sidecar) + self._undo_clear_fit_result_state() + return UndoFitOutcome( + restored_parameter_names=restored_names, + cleared_fit_result=cleared_fit_result, + cleared_sidecar=cleared_sidecar, + was_no_op=False, + ) + + def _undo_start_rows(self) -> list[object]: + """Return fit-parameter rows with saved start values.""" + return [row for row in self.fit_parameters if row.start_value.value is not None] + + def _undo_is_noop(self) -> bool: + """Return whether undo has no work to perform.""" + if self._has_persisted_fit_state(): + return False + + rows = self._undo_start_rows() + if not rows: + return True + + param_map = self._live_parameter_map() + return all(self._is_parameter_at_undo_start(row=row, param_map=param_map) for row in rows) + + @staticmethod + def _is_parameter_at_undo_start( + *, + row: object, + param_map: dict[str, Parameter], + ) -> bool: + """Return whether one live parameter is already at start.""" + parameter = param_map.get(row.param_unique_name.value) + if parameter is None: + return True + return isclose( + float(parameter.value), + float(row.start_value.value), + rel_tol=_UNDO_REL_TOL, + abs_tol=_UNDO_ABS_TOL, + ) + + def _undo_scalar_rollback(self) -> tuple[str, ...]: + """Restore live scalar values from fit-parameter rows.""" + restored_names: list[str] = [] + param_map = self._live_parameter_map() + logged_missing_uncertainty = False + for row in self._undo_start_rows(): + parameter = param_map.get(row.param_unique_name.value) + if parameter is None: + log.warning( + 'Persisted fit-state references unknown parameter ' + f'{row.param_unique_name.value!r}.' + ) + continue + + parameter.value = row.start_value.value + if row.start_uncertainty.value is None: + parameter.uncertainty = None + if not logged_missing_uncertainty: + log.info( + 'No saved pre-fit uncertainties found; ' + 'clearing restored parameter uncertainties.' + ) + logged_missing_uncertainty = True + else: + parameter.uncertainty = row.start_uncertainty.value + parameter._set_posterior(None) + restored_names.append(row.param_unique_name.value) + return tuple(restored_names) + + def _undo_clear_per_row_posterior_fields(self) -> None: + """Clear fit-derived posterior fields on fit-parameter rows.""" + for row in self.fit_parameters: + row._set_posterior_best_sample_value(None) + row._set_posterior_median(None) + row._set_posterior_uncertainty(None) + row._set_posterior_interval_68_low(None) + row._set_posterior_interval_68_high(None) + row._set_posterior_interval_95_low(None) + row._set_posterior_interval_95_high(None) + row._set_posterior_gelman_rubin(None) + row._set_posterior_effective_sample_size_bulk(None) + + def _undo_clear_fit_result_state(self) -> None: + """Clear fit-derived analysis state after scalar rollback.""" + self._clear_fit_result_projection() + self._fit_parameter_correlations = FitParameterCorrelations() + self._persisted_fit_state_sidecar = {} + self._set_has_persisted_fit_state(value=False) + self.fit_results = None + def _run_fit_mode( self, *, @@ -1479,10 +1636,17 @@ def _set_has_persisted_fit_state(self, *, value: bool) -> None: """Set the persisted fit-state presence flag.""" self._has_persisted_fit_state_data = value - def _fit_state_categories(self) -> list[object]: - """Return fit-state categories for the current result kind.""" + def _fit_parameter_state_categories(self) -> list[object]: + """Return persisted fit-parameter rows when present.""" + if not self.fit_parameters: + return [] + return [self.fit_parameters] + + def _fit_result_state_categories(self) -> list[object]: + """ + Return fit-result state categories for the current result kind. + """ categories: list[object] = [ - self.fit_parameters, self.fit_result, self.fit_parameter_correlations, ] diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py index 873aac0ee..b7ce44692 100644 --- a/src/easydiffraction/io/cif/serialize.py +++ b/src/easydiffraction/io/cif/serialize.py @@ -573,26 +573,29 @@ def analysis_from_cif(analysis: object, cif_text: str) -> None: if analysis.constraints._items: analysis.constraints.enable() + if _has_fit_parameter_state_sections(block): + _restore_fit_parameter_state(analysis, block) if _has_persisted_fit_state_sections(block): _restore_persisted_fit_state(analysis, block) -def _has_persisted_fit_state_sections(block: object) -> bool: - """Return True when any persisted fit-state section is present.""" - scalar_tags = ('_fit_result.result_kind',) - loop_tags = ( - '_fit_parameter.param_unique_name', - '_fit_parameter_correlation.param_unique_name_i', - ) +def _has_fit_parameter_state_sections(block: object) -> bool: + """Return True when persisted fit-parameter rows are present.""" + return _has_cif_loop(block, '_fit_parameter.param_unique_name') - return any(_has_cif_value(block, tag) for tag in scalar_tags) or any( - _has_cif_loop(block, tag) for tag in loop_tags - ) +def _has_persisted_fit_state_sections(block: object) -> bool: + """Return True when a fit-result projection is present.""" + return _has_cif_value(block, '_fit_result.result_kind') -def _restore_common_fit_state(analysis: object, block: object) -> None: - """Restore fit-state categories shared by both fit kinds.""" + +def _restore_fit_parameter_state(analysis: object, block: object) -> None: + """Restore fit-parameter rows independently of fit results.""" analysis.fit_parameters.from_cif(block) + + +def _restore_fit_result_state(analysis: object, block: object) -> None: + """Restore categories that describe the latest fit result.""" analysis.fit_result.from_cif(block) analysis.fit_parameter_correlations.from_cif(block) @@ -604,7 +607,7 @@ def _restore_persisted_fit_state(analysis: object, block: object) -> None: from easydiffraction.analysis.enums import FitResultKindEnum # noqa: PLC0415 analysis._set_has_persisted_fit_state(value=True) - _restore_common_fit_state(analysis, block) + _restore_fit_result_state(analysis, block) result_kind_value = analysis.fit_result.result_kind.value try: diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index 3f64ab20b..ccd4c1849 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -172,8 +172,11 @@ def _load_project_analysis(project: Project, project_path: pathlib.Path) -> None analysis=project._analysis, analysis_dir=analysis_cif_path.parent, ) + param_map = project._build_parameter_map() + if project._analysis.fit_parameters: + project._analysis._restore_live_parameter_bounds_and_anchors(param_map) if project._analysis._has_persisted_fit_state(): - project._analysis._restore_live_parameter_state(project._build_parameter_map()) + project._analysis._restore_live_parameter_posterior(param_map) class Project(GuardedBase): # noqa: PLR0904 diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py index 2af1f53b1..31615f9d0 100644 --- a/src/easydiffraction/utils/utils.py +++ b/src/easydiffraction/utils/utils.py @@ -1040,13 +1040,13 @@ def str_to_ufloat(s: str | None, default: float | None = None) -> UFloat: Parse a CIF-style numeric string into a ufloat. Examples of supported input: - "3.566" → ufloat(3.566, nan) - - "3.566(2)" → ufloat(3.566, 0.002) - "3.566()" → ufloat(3.566, 0.0) - + "3.566(2)" → ufloat(3.566, 0.002) - "3.566()" → ufloat(3.566, nan) - None → ufloat(default, nan) Behavior: - If the input string contains a value with parentheses (e.g. "3.566(2)"), the number in parentheses is interpreted as an estimated standard deviation (esd) in the last digit(s). - Empty - parentheses (e.g. "3.566()") are treated as zero uncertainty. - If + parentheses (e.g. "3.566()") are treated as "no esd provided". - If the input string has no parentheses, an uncertainty of NaN is assigned to indicate "no esd provided". - If parsing fails, the function falls back to the given ``default`` value with uncertainty @@ -1073,8 +1073,8 @@ def str_to_ufloat(s: str | None, default: float | None = None) -> UFloat: if '(' not in s and ')' not in s: s = f'{s}(nan)' elif s.endswith('()'): - # Empty brackets → zero uncertainty (free parameter, no esd yet) - s = s[:-2] + '(0)' + # Empty brackets mark refinement intent, not a zero esd. + s = s[:-2] + '(nan)' try: return ufloat_fromstr(s) except ValueError: diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 01f54ce0c..110b6ebe9 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -30,6 +30,51 @@ class P: return P() +def _make_parameter(name, value): + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + + return Parameter( + name=name, + value_spec=AttributeSpec(default=value), + cif_handler=CifHandler(names=[f'_{name}.value']), + ) + + +def _make_project_with_parameters(parameters): + class ParamContainer: + def __init__(self, parameters): + self.parameters = list(parameters) + + class Experiments(ParamContainer): + names = [] + + def values(self): + return [] + + return SimpleNamespace( + structures=ParamContainer(parameters), + experiments=Experiments([]), + info=SimpleNamespace(path=None), + _varname='proj', + ) + + +def _posterior_field_values(row): + return ( + row.posterior_best_sample_value.value, + row.posterior_median.value, + row.posterior_uncertainty.value, + row.posterior_interval_68_low.value, + row.posterior_interval_68_high.value, + row.posterior_interval_95_low.value, + row.posterior_interval_95_high.value, + row.posterior_gelman_rubin.value, + row.posterior_effective_sample_size_bulk.value, + ) + + def test_minimizer_show_supported_prints(capsys): from easydiffraction.analysis.analysis import Analysis @@ -119,6 +164,152 @@ def test_minimizer_selector_swap_warns_for_different_defaults(monkeypatch): assert not any('' in w for w in warnings) +def test_undo_fit_restores_scalars_and_clears_fit_outputs(): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.core.posterior import PosteriorParameterSummary + + length_a = _make_parameter('length_a', 3.90) + length_b = _make_parameter('length_b', 3.95) + project = _make_project_with_parameters([length_a, length_b]) + analysis = Analysis(project=project) + + length_a.value = 4.10 + length_a.uncertainty = 0.08 + length_b.value = 4.15 + length_b.uncertainty = 0.09 + for parameter, start_value, start_uncertainty in ( + (length_a, 3.90, 0.02), + (length_b, 3.95, 0.03), + ): + parameter.fit_min = 3.5 + parameter.fit_max = 4.5 + parameter._set_fit_bounds_uncertainty_multiplier(4.0) + summary = PosteriorParameterSummary( + unique_name=parameter.unique_name, + display_name=parameter.name, + best_sample_value=parameter.value, + median=parameter.value, + standard_deviation=0.01, + interval_68=(parameter.value - 0.01, parameter.value + 0.01), + interval_95=(parameter.value - 0.02, parameter.value + 0.02), + ess_bulk=100.0, + r_hat=1.01, + ) + parameter._set_posterior(summary) + analysis.fit_parameters.create( + param_unique_name=parameter.unique_name, + fit_min=parameter.fit_min, + fit_max=parameter.fit_max, + fit_bounds_uncertainty_multiplier=4.0, + start_value=start_value, + start_uncertainty=start_uncertainty, + ) + analysis.fit_parameters[parameter.unique_name]._set_posterior_summary(summary) + + analysis.fit_result._set_result_kind('deterministic') + analysis.fit_result._set_success(value=True) + analysis.fit_parameter_correlations.create( + source_kind='deterministic', + param_unique_name_i=length_a.unique_name, + param_unique_name_j=length_b.unique_name, + correlation=0.25, + ) + analysis._persisted_fit_state_sidecar = {'posterior': {'draws': object()}} + analysis._set_has_persisted_fit_state(value=True) + analysis.fit_results = object() + analysis.fitter.results = object() + + outcome = analysis.undo_fit() + + assert outcome.restored_parameter_names == (length_a.unique_name, length_b.unique_name) + assert outcome.cleared_fit_result is True + assert outcome.cleared_sidecar is True + assert outcome.was_no_op is False + assert length_a.value == 3.90 + assert length_a.uncertainty == 0.02 + assert length_a.posterior is None + assert length_b.value == 3.95 + assert length_b.uncertainty == 0.03 + assert length_b.posterior is None + assert _posterior_field_values(analysis.fit_parameters[length_a.unique_name]) == (None,) * 9 + assert _posterior_field_values(analysis.fit_parameters[length_b.unique_name]) == (None,) * 9 + assert analysis.fit_results is None + assert analysis.fitter.results is None + assert analysis._has_persisted_fit_state() is False + assert len(analysis.fit_parameter_correlations) == 0 + assert analysis._persisted_fit_state_sidecar == {} + + +def test_undo_fit_second_call_is_noop(monkeypatch): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + parameter = _make_parameter('scale', 1.0) + project = _make_project_with_parameters([parameter]) + analysis = Analysis(project=project) + parameter.value = 1.5 + analysis.fit_parameters.create( + param_unique_name=parameter.unique_name, + fit_min=0.0, + fit_max=2.0, + start_value=1.0, + start_uncertainty=0.1, + ) + analysis.fit_result._set_result_kind('deterministic') + analysis._set_has_persisted_fit_state(value=True) + messages: list[str] = [] + monkeypatch.setattr(analysis_mod.log, 'info', messages.append) + + first_outcome = analysis.undo_fit() + second_outcome = analysis.undo_fit() + + assert first_outcome.was_no_op is False + assert second_outcome.was_no_op is True + assert second_outcome.restored_parameter_names == () + assert messages == ['No fit to undo.'] + + +def test_undo_fit_never_fit_project_is_noop(monkeypatch): + from easydiffraction.analysis import analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + analysis = Analysis(project=_make_project_with_parameters([_make_parameter('scale', 1.0)])) + messages: list[str] = [] + monkeypatch.setattr(analysis_mod.log, 'info', messages.append) + + outcome = analysis.undo_fit() + + assert outcome.was_no_op is True + assert outcome.restored_parameter_names == () + assert outcome.cleared_fit_result is False + assert outcome.cleared_sidecar is False + assert messages == ['No fit to undo.'] + + +def test_undo_fit_loaded_no_movement_fit_is_not_noop(): + from easydiffraction.analysis.analysis import Analysis + + parameter = _make_parameter('scale', 1.0) + project = _make_project_with_parameters([parameter]) + analysis = Analysis(project=project) + analysis.fit_parameters.create( + param_unique_name=parameter.unique_name, + fit_min=0.0, + fit_max=2.0, + start_value=1.0, + start_uncertainty=0.1, + ) + analysis.fit_result._set_result_kind('deterministic') + analysis._set_has_persisted_fit_state(value=True) + + outcome = analysis.undo_fit() + + assert outcome.was_no_op is False + assert outcome.restored_parameter_names == (parameter.unique_name,) + assert outcome.cleared_fit_result is True + assert analysis._has_persisted_fit_state() is False + + def test_minimizer_type_invalid_assignment_raises_and_preserves_state(): import pytest diff --git a/tests/unit/easydiffraction/io/cif/test_serialize.py b/tests/unit/easydiffraction/io/cif/test_serialize.py index 375e5aa86..9f78a210b 100644 --- a/tests/unit/easydiffraction/io/cif/test_serialize.py +++ b/tests/unit/easydiffraction/io/cif/test_serialize.py @@ -68,6 +68,31 @@ def test_format_param_value_with_large_uncertainty_is_readable(): assert MUT.format_param_value(p) == '882(58)' +def test_param_from_cif_empty_brackets_marks_free_without_uncertainty(): + import warnings + + import gemmi + + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + + p = Parameter( + name='2theta_offset', + value_spec=AttributeSpec(default=0.0), + cif_handler=CifHandler(names=['_instr.2theta_offset']), + ) + doc = gemmi.cif.read_string('data_test\n_instr.2theta_offset 0.5()\n') + + with warnings.catch_warnings(): + warnings.simplefilter('error') + p.from_cif(doc.sole_block()) + + assert p.value == 0.5 + assert p.free is True + assert p.uncertainty is None + + def test_category_collection_to_cif_empty_and_one_row(): import easydiffraction.io.cif.serialize as MUT from easydiffraction.core.category import CategoryCollection @@ -122,3 +147,33 @@ def __init__(self): p = Project() out = MUT.project_to_cif(p) assert out == 'I\n\nE' + + +def test_analysis_from_cif_restores_fit_parameters_without_fit_result(): + import easydiffraction.io.cif.serialize as MUT + + from easydiffraction.analysis.analysis import Analysis + + class Project: + structures = type('Structures', (), {'parameters': []})() + experiments = type('Experiments', (), {'parameters': [], 'names': []})() + _varname = 'proj' + + analysis = Analysis(project=Project()) + cif_text = """ +_fitting_mode.type single +_minimizer.type 'lmfit (leastsq)' +loop_ +_fit_parameter.param_unique_name +_fit_parameter.fit_min +_fit_parameter.fit_max +_fit_parameter.start_value +_fit_parameter.start_uncertainty +scale 0.0 2.0 1.0 0.1 +""" + + MUT.analysis_from_cif(analysis, cif_text) + + assert analysis._has_persisted_fit_state() is False + assert len(analysis.fit_parameters) == 1 + assert analysis.fit_parameters['scale'].start_value.value == 1.0 diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py index 0236b7689..446e1b7f6 100644 --- a/tests/unit/easydiffraction/project/test_project.py +++ b/tests/unit/easydiffraction/project/test_project.py @@ -122,3 +122,59 @@ def values(): project.apply_params_from_csv(0) assert loaded_paths == [str(data_path)] + + +def test_undo_fit_save_reload_preserves_fit_parameter_controls(tmp_path): + from easydiffraction.project.project import Project + + project = Project(name='undo_reload') + project.structures.create(name='lbco') + structure = project.structures['lbco'] + structure.space_group.name_h_m = 'P m -3 m' + parameter = structure.cell.length_a + parameter.free = True + parameter.value = 3.91 + parameter.uncertainty = 0.04 + parameter.fit_min = 3.8 + parameter.fit_max = 4.0 + parameter._set_fit_bounds_uncertainty_multiplier(4.0) + + project.analysis.fit_parameters.create( + param_unique_name=parameter.unique_name, + fit_min=parameter.fit_min, + fit_max=parameter.fit_max, + fit_bounds_uncertainty_multiplier=4.0, + start_value=3.87, + start_uncertainty=0.02, + ) + project.analysis.fit_result._set_result_kind('deterministic') + project.analysis.fit_result._set_success(value=True) + project.analysis.fit_result._set_message('Fit converged') + project.analysis.fit_result._set_iterations(12) + project.analysis.fit_result._set_fitting_time(0.5) + project.analysis.fit_result._set_reduced_chi_square(1.1) + project.analysis._set_has_persisted_fit_state(value=True) + project.save_as(str(tmp_path / 'proj')) + + outcome = project.analysis.undo_fit() + project.save() + loaded = Project.load(str(tmp_path / 'proj')) + loaded_parameter = loaded.structures['lbco'].cell.length_a + loaded_row = loaded.analysis.fit_parameters[loaded_parameter.unique_name] + second_outcome = loaded.analysis.undo_fit() + + assert outcome.was_no_op is False + assert loaded.analysis._has_persisted_fit_state() is False + assert loaded.analysis.fit_results is None + assert loaded_row.fit_min.value == 3.8 + assert loaded_row.fit_max.value == 4.0 + assert loaded_row.fit_bounds_uncertainty_multiplier.value == 4.0 + assert loaded_row.start_value.value == 3.87 + assert loaded_row.start_uncertainty.value == 0.02 + assert loaded_parameter.value == 3.87 + assert loaded_parameter.fit_min == 3.8 + assert loaded_parameter.fit_max == 4.0 + assert loaded_parameter.fit_bounds_uncertainty_multiplier == 4.0 + assert loaded_parameter._fit_start_value == 3.87 + assert loaded_parameter._fit_start_uncertainty == 0.02 + assert second_outcome.was_no_op is True diff --git a/tests/unit/easydiffraction/test___main__.py b/tests/unit/easydiffraction/test___main__.py index 52623534c..ad6c3935c 100644 --- a/tests/unit/easydiffraction/test___main__.py +++ b/tests/unit/easydiffraction/test___main__.py @@ -261,3 +261,113 @@ def pattern(expt_name, **kwargs): result = runner.invoke(main_mod.app, ['fit', '--dry', str(proj_dir)]) assert result.exit_code == 0 assert fake_project.info._path is None + + +def test_cli_undo_noop_exits_zero_and_does_not_save(monkeypatch, tmp_path): + import easydiffraction.__main__ as main_mod + from easydiffraction.analysis import UndoFitOutcome + + calls: list[str] = [] + + class FakeAnalysis: + @staticmethod + def undo_fit(): + calls.append('UNDO') + return UndoFitOutcome( + restored_parameter_names=(), + cleared_fit_result=False, + cleared_sidecar=False, + was_no_op=True, + ) + + class FakeProject: + name = 'demo_project' + analysis = FakeAnalysis() + + @staticmethod + def save(): + calls.append('SAVE') + + proj_dir = tmp_path / 'proj' + monkeypatch.setattr(main_mod, '_load_project', lambda project_dir: FakeProject()) + + result = runner.invoke(main_mod.app, ['undo', str(proj_dir)]) + + assert result.exit_code == 0 + assert calls == ['UNDO'] + assert "No fit to undo for 'demo_project'. Project state is unchanged." in result.stdout + + +def test_cli_undo_dry_uses_outcome_summary_without_saving(monkeypatch, tmp_path): + import easydiffraction.__main__ as main_mod + from easydiffraction.analysis import UndoFitOutcome + + calls: list[str] = [] + + class FakeAnalysis: + @staticmethod + def undo_fit(): + calls.append('UNDO') + return UndoFitOutcome( + restored_parameter_names=('a', 'b'), + cleared_fit_result=True, + cleared_sidecar=True, + was_no_op=False, + ) + + class FakeProject: + name = 'demo_project' + analysis = FakeAnalysis() + + @staticmethod + def save(): + calls.append('SAVE') + + proj_dir = tmp_path / 'proj' + monkeypatch.setattr(main_mod, '_load_project', lambda project_dir: FakeProject()) + + result = runner.invoke(main_mod.app, ['undo', '--dry', str(proj_dir)]) + + assert result.exit_code == 0 + assert calls == ['UNDO'] + assert "Would undo last fit for 'demo_project'" in result.stdout + assert '2 parameters would be restored to pre-fit values' in result.stdout + assert 'analysis.fit_results would be cleared' in result.stdout + assert 'analysis/results.h5 (Bayesian sidecar) would be cleared' in result.stdout + + +def test_cli_undo_saves_after_real_rollback(monkeypatch, tmp_path): + import easydiffraction.__main__ as main_mod + from easydiffraction.analysis import UndoFitOutcome + + calls: list[str] = [] + + class FakeAnalysis: + @staticmethod + def undo_fit(): + calls.append('UNDO') + return UndoFitOutcome( + restored_parameter_names=('a',), + cleared_fit_result=True, + cleared_sidecar=False, + was_no_op=False, + ) + + class FakeProject: + name = 'demo_project' + analysis = FakeAnalysis() + + @staticmethod + def save(): + calls.append('SAVE') + + proj_dir = tmp_path / 'proj' + monkeypatch.setattr(main_mod, '_load_project', lambda project_dir: FakeProject()) + + result = runner.invoke(main_mod.app, ['undo', str(proj_dir)]) + + assert result.exit_code == 0 + assert calls == ['UNDO', 'SAVE'] + assert 'Restored 1 parameters to their pre-fit values.' in result.stdout + assert 'Cleared analysis.fit_results.' in result.stdout + assert f'Saved project to {proj_dir}.' in result.stdout diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py index 87f2d71b3..00f903b58 100644 --- a/tests/unit/easydiffraction/utils/test_utils.py +++ b/tests/unit/easydiffraction/utils/test_utils.py @@ -460,7 +460,8 @@ def __exit__(self, *args): assert (expected_dir / 'ed-1.ipynb').exists() out = capsys.readouterr().out assert 'Downloaded 1 tutorials' in out - assert 'artifacts/tutorials' in out + normalized_out = out.replace('\\', '/').replace('\n', '') + assert str(expected_dir).replace('\\', '/') in normalized_out def test_resolve_tutorial_url(): diff --git a/tests/unit/easydiffraction/utils/test_utils_coverage.py b/tests/unit/easydiffraction/utils/test_utils_coverage.py index d2d07159e..d9a1a0f20 100644 --- a/tests/unit/easydiffraction/utils/test_utils_coverage.py +++ b/tests/unit/easydiffraction/utils/test_utils_coverage.py @@ -255,12 +255,17 @@ def test_str_to_ufloat_none_no_default_raises(): MUT.str_to_ufloat(None) -def test_str_to_ufloat_empty_brackets_zero_uncertainty(): +def test_str_to_ufloat_empty_brackets_mark_missing_uncertainty(): + import warnings + import easydiffraction.utils.utils as MUT - u = MUT.str_to_ufloat('3.566()') + with warnings.catch_warnings(): + warnings.simplefilter('error') + u = MUT.str_to_ufloat('3.566()') + assert np.isclose(u.nominal_value, 3.566) - assert np.isclose(u.std_dev, 0.0) + assert np.isnan(u.std_dev) def test_str_to_ufloat_invalid_string_returns_default(): From f178c9edd133efd3aec055b4731b499d12f06f8b Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 26 May 2026 18:05:27 +0200 Subject: [PATCH 05/12] Align IUCr CIF tags and report export (#184) --- .../adrs/accepted/analysis-cif-fit-state.md | 45 +- .../accepted/fit-results-display-naming.md | 10 +- .../dev/adrs/accepted/help-discoverability.md | 10 +- .../adrs/accepted/iucr-cif-tag-alignment.md | 1136 ++++++++++++++ .../accepted/minimizer-input-output-split.md | 28 +- .../project-facade-and-persistence.md | 20 +- docs/dev/adrs/index.md | 1 + .../suggestions/iucr-cif-tag-alignment.md | 184 --- docs/dev/package-structure/full.md | 25 +- docs/dev/package-structure/short.md | 7 +- docs/dev/plans/iucr-cif-tag-alignment.md | 632 ++++++++ docs/docs/api-reference/index.md | 2 +- docs/docs/api-reference/report.md | 1 + docs/docs/api-reference/summary.md | 1 - docs/docs/tutorials/ed-13.ipynb | 4 +- docs/docs/tutorials/ed-13.py | 2 +- docs/docs/tutorials/ed-14.ipynb | 3 +- docs/docs/tutorials/ed-14.py | 1 + docs/docs/tutorials/ed-3.ipynb | 15 +- docs/docs/tutorials/ed-3.py | 13 +- docs/docs/tutorials/ed-5.ipynb | 6 +- docs/docs/tutorials/ed-5.py | 6 +- docs/docs/tutorials/ed-6.ipynb | 6 +- docs/docs/tutorials/ed-6.py | 6 +- docs/docs/tutorials/ed-8.ipynb | 6 +- docs/docs/tutorials/ed-8.py | 6 +- .../user-guide/analysis-workflow/analysis.md | 2 +- .../user-guide/analysis-workflow/index.md | 7 +- .../user-guide/analysis-workflow/project.md | 5 +- .../{summary.md => report.md} | 38 +- docs/mkdocs.yml | 4 +- src/easydiffraction/analysis/analysis.py | 230 +++ .../analysis/categories/aliases/default.py | 10 +- .../categories/constraints/default.py | 10 +- .../categories/fit_result/bayesian.py | 45 +- .../analysis/categories/fit_result/lsq.py | 479 +++++- .../categories/fitting_mode/default.py | 5 +- .../analysis/categories/joint_fit/default.py | 10 +- .../analysis/categories/minimizer/base.py | 5 +- .../categories/minimizer/bayesian_base.py | 35 +- .../analysis/categories/minimizer/emcee.py | 10 +- .../analysis/categories/minimizer/lsq_base.py | 5 +- .../categories/sequential_fit/default.py | 25 +- .../sequential_fit_extract/default.py | 20 +- .../experiment/categories/background/base.py | 5 +- .../categories/calculator/default.py | 5 +- .../experiment/categories/diffrn/default.py | 10 +- .../categories/excluded_regions/default.py | 15 +- .../categories/experiment_type/default.py | 20 +- .../experiment/categories/extinction/base.py | 5 +- .../categories/extinction/becker_coppens.py | 15 +- .../categories/linked_crystal/default.py | 10 +- .../experiment/categories/peak/base.py | 5 +- .../experiment/categories/peak/cwl_mixins.py | 55 +- .../experiment/categories/peak/tof_mixins.py | 90 +- .../categories/peak/total_mixins.py | 30 +- .../categories/atom_sites/default.py | 41 +- .../categories/space_group/default.py | 2 + src/easydiffraction/io/cif/handler.py | 10 +- .../io/cif/iucr_transformers.py | 435 ++++++ src/easydiffraction/io/cif/iucr_writer.py | 1373 +++++++++++++++++ src/easydiffraction/io/cif/serialize.py | 177 ++- src/easydiffraction/project/project.py | 33 +- .../{summary => report}/__init__.py | 5 + src/easydiffraction/report/check.py | 141 ++ .../{summary/summary.py => report/report.py} | 99 +- tests/functional/test_adp_switching.py | 4 +- .../fitting/test_exploration_help.py | 2 +- .../fitting/test_summary_report.py | 21 +- .../categories/fit_result/test_lsq.py | 86 ++ .../easydiffraction/analysis/test_analysis.py | 17 + .../structure/categories/test_atom_sites.py | 15 + .../structure/categories/test_space_group.py | 19 + .../easydiffraction/io/cif/test_handler.py | 19 + .../io/cif/test_iucr_transformers.py | 188 +++ .../io/cif/test_iucr_writer.py | 344 +++++ .../easydiffraction/io/cif/test_serialize.py | 40 + .../easydiffraction/project/test_project.py | 37 +- .../test_project_load_and_summary_wrap.py | 8 +- .../project/test_project_save.py | 14 +- .../unit/easydiffraction/report/test_check.py | 38 + .../easydiffraction/report/test_report.py | 107 ++ .../test_report_details.py} | 14 +- .../easydiffraction/summary/test_summary.py | 72 - 84 files changed, 6113 insertions(+), 634 deletions(-) create mode 100644 docs/dev/adrs/accepted/iucr-cif-tag-alignment.md delete mode 100644 docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md create mode 100644 docs/dev/plans/iucr-cif-tag-alignment.md create mode 100644 docs/docs/api-reference/report.md delete mode 100644 docs/docs/api-reference/summary.md rename docs/docs/user-guide/analysis-workflow/{summary.md => report.md} (55%) create mode 100644 src/easydiffraction/io/cif/iucr_transformers.py create mode 100644 src/easydiffraction/io/cif/iucr_writer.py rename src/easydiffraction/{summary => report}/__init__.py (52%) create mode 100644 src/easydiffraction/report/check.py rename src/easydiffraction/{summary/summary.py => report/report.py} (73%) create mode 100644 tests/unit/easydiffraction/io/cif/test_iucr_transformers.py create mode 100644 tests/unit/easydiffraction/io/cif/test_iucr_writer.py create mode 100644 tests/unit/easydiffraction/report/test_check.py create mode 100644 tests/unit/easydiffraction/report/test_report.py rename tests/unit/easydiffraction/{summary/test_summary_details.py => report/test_report_details.py} (91%) delete mode 100644 tests/unit/easydiffraction/summary/test_summary.py diff --git a/docs/dev/adrs/accepted/analysis-cif-fit-state.md b/docs/dev/adrs/accepted/analysis-cif-fit-state.md index 8b31da419..b15b1ed52 100644 --- a/docs/dev/adrs/accepted/analysis-cif-fit-state.md +++ b/docs/dev/adrs/accepted/analysis-cif-fit-state.md @@ -85,7 +85,13 @@ posterior summaries: - `posterior_effective_sample_size_bulk` `_fit_result` stores the latest saved fit header and scalar -family-specific fit outputs: +family-specific fit outputs. In the default project save this category +is topology-neutral: single-crystal and powder fits both persist under +`_fit_result.*`. The IUCr submission export may remap these same values +to topology-specific dictionary categories (`_refine_ls.*`, +`_pd_proc_ls.*`, `_reflns.*`) as described by +[`iucr-cif-tag-alignment.md`](iucr-cif-tag-alignment.md), but the +round-trip project schema remains common. - `result_kind` - `success` @@ -113,6 +119,43 @@ Deterministic fit-result classes add compact fit output counts: - `covariance_available` - `correlation_available` +These deterministic fields are always written once a deterministic +fit-result projection exists. + +Reflection-result fields are written only when a fitted experiment has +persisted reflection rows: + +- `R_factor_all` +- `wR_factor_all` +- `R_factor_gt` +- `wR_factor_gt` +- `threshold_expression` +- `number_reflns_total` +- `number_reflns_gt` + +Powder-profile fields are written only when the result contains powder +profile diagnostics: + +- `prof_R_factor` +- `prof_wR_factor` +- `prof_wR_expected` +- `profile_function` +- `background_function` + +Restraint and constraint counts are written only when positive: + +- `number_restraints` +- `number_constraints` + +The deterministic R-factor, profile, restraint / constraint, and +reflection-aggregate fields use dictionary-canonical item names where +those exist, including uppercase `R` / `wR`, while retaining the +project-side `_fit_result` category prefix in the default save. Live +deterministic fit results may also carry transient diagnostics such as +`shift_over_su_max` and `shift_over_su_mean`; those are not written to +`analysis/analysis.cif` until a topology-specific persistence contract +needs them. + When the LSQ backend provides a termination reason that differs from the common `_fit_result.message`, deterministic fit results also store: diff --git a/docs/dev/adrs/accepted/fit-results-display-naming.md b/docs/dev/adrs/accepted/fit-results-display-naming.md index 5704102e1..afcf508a5 100644 --- a/docs/dev/adrs/accepted/fit-results-display-naming.md +++ b/docs/dev/adrs/accepted/fit-results-display-naming.md @@ -51,8 +51,8 @@ Both converge on `s.u.` as the appropriate cross-method label. [`display-ux.md`](display-ux.md) defines facade method names but not column headers or footnotes; -[`iucr-cif-tag-alignment.md`](../suggestions/iucr-cif-tag-alignment.md) -defines persisted CIF tag names but not display labels; +[`iucr-cif-tag-alignment.md`](iucr-cif-tag-alignment.md) defines +persisted CIF tag names but not display labels; [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) defines Python and CIF attribute names but not user-visible labels. Display naming for fit-results tables is a real gap. @@ -281,9 +281,9 @@ None directly amended. This ADR complements: - [`display-ux.md`](display-ux.md) — defines facade method names; this ADR fills in the column-header layer underneath. -- [`iucr-cif-tag-alignment.md`](../suggestions/iucr-cif-tag-alignment.md) - — defines persisted CIF tag names; this ADR is the matching - display-time label layer. +- [`iucr-cif-tag-alignment.md`](iucr-cif-tag-alignment.md) — defines + persisted CIF tag names; this ADR is the matching display-time label + layer. - [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) — defines Python / CIF attribute names (e.g. `Parameter.uncertainty`, `posterior_uncertainty`); display headers map to those without diff --git a/docs/dev/adrs/accepted/help-discoverability.md b/docs/dev/adrs/accepted/help-discoverability.md index 8fd019f39..a3a1abace 100644 --- a/docs/dev/adrs/accepted/help-discoverability.md +++ b/docs/dev/adrs/accepted/help-discoverability.md @@ -9,14 +9,14 @@ Accepted and implemented. EasyDiffraction is used by scientists who often explore the API in notebooks. The main object graph already exposes many focused objects: projects, project metadata, structures, experiments, categories, -parameters, analysis helpers, summaries, and display facades. Users need -a consistent way to discover the next useful operation from any of these +parameters, analysis helpers, reports, and display facades. Users need a +consistent way to discover the next useful operation from any of these objects without reading source code. Most model objects inherit `GuardedBase`, `CategoryItem`, `CategoryCollection`, `DatablockItem`, or `DatablockCollection`, which already provide `help()` output. Plain facade classes such as display -namespaces and summaries do not inherit those base classes, so they need +namespaces and reports do not inherit those base classes, so they need the same discovery behavior explicitly. ## Decision @@ -28,7 +28,7 @@ includes: - category items and category collections - datablock items and datablock collections - project-level objects such as `Project`, `ProjectInfo`, `Analysis`, - `Summary`, and `Rendering` + `Report`, and `Rendering` - display facades such as `project.display`, `project.display.parameters`, `project.display.fit`, `project.display.posterior`, and `analysis.display` @@ -52,7 +52,7 @@ project.help() project.display.help() project.display.parameters.help() project.analysis.display.help() -project.summary.help() +project.report.help() project.experiments.help() project.experiments['hrpt'].help() project.experiments['hrpt'].background.help() diff --git a/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md new file mode 100644 index 000000000..119bb677e --- /dev/null +++ b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md @@ -0,0 +1,1136 @@ +# ADR: IUCr CIF Tag Alignment + +**Status:** Accepted +**Date:** 2026-05-26 + +Reframes the earlier "IUCr CIF Tag Alignment for Fit Outputs" suggestion +(2026-05-24, PR #181) into a tiered policy. The default saved CIFs stay +optimised for day-to-day UX; a separate IUCr export path produces +journal-submission CIFs on demand. Amends parts of +[`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) and +[`minimizer-input-output-split.md`](minimizer-input-output-split.md); +runs alongside the +[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +suggestion (Python-side correspondence). + +Grounded in: + +- COMCIFS + [`cif_core.dic`](https://raw.githubusercontent.com/COMCIFS/cif_core/main/cif_core.dic) + v3.4.0 (2026-05-05; 787 `_alias.definition_id` entries). +- COMCIFS + [`cif_pow.dic`](https://raw.githubusercontent.com/COMCIFS/Powder_Dictionary/master/cif_pow.dic) + v2.5.0 (2026-05-19; 180 `_alias.definition_id` entries). + +Both reference dictionaries are DDLm CIF_2.0 files. Their canonical +identifiers are dotted (`_definition.id '_pd_instr.geometry'`); the +legacy DDL1 underscore form is recorded as `_alias.definition_id` on +every item. The project emits the **dotted DDLm form universally** +(default save and IUCr export) and accepts both forms on read via the +dictionaries' alias tables. + +A corpus of 10 published IUCr submission CIFs in `tmp/iucr-cifs/` +covering single-crystal (X-ray, neutron) and powder (lab X-ray, neutron, +synchrotron) refinements informs the **structural** decisions in §2 — +multi-datablock layout, `data_global` content patterns, reflection and +profile loop column sets, GSAS-II Rietveld block split. The corpus is +**not** authoritative for tag form, casing, or item names when it +disagrees with the reference dictionaries; example CIFs in the wild are +commonly produced by tooling that has not yet caught up with the current +DDLm spec. The dictionaries are the source of truth. + +The submission-specific publication dictionary (`cif_publ.dic`) is not +consulted directly — the publication-block items are all present in +`cif_core.dic` under `_journal.*`, `_journal_coeditor.*`, +`_journal_date.*`, `_publ_author.*`, `_publ_contact_author.*`, +`_publ_body.*`, `_publ_manuscript.*`, `_audit.*`, and +`_chemical_formula.*`. + +## Context + +EasyDiffraction saves project state into per-domain CIF files +(`project.cif`, `structures/.cif`, `experiments/.cif`, +`analysis/analysis.cif`). Two pressures act on the choice of category +and item names: + +- **External interop.** Some current names diverge from the published + IUCr dictionaries. External tooling (publCIF, checkCIF, pdCIFplotter, + journal submission pipelines) cannot consume the diverging fields + without a custom mapping layer. +- **Day-to-day UX.** Users switch between Python and direct CIF editing + in a CLI. Some IUCr-canonical structures are awkward for hand editing + — submission templates require multi-datablock layouts with + `data_global` publication metadata, embedded `_publ_*` placeholder + fields, and TOF calibration as a coefficient loop indexed by integer + `power`. Parametric profile shape (Caglioti, FCJ, TOF sigma/gamma) has + no IUCr counterpart at all. + +A blanket "align with IUCr everywhere" policy pays a UX cost the project +does not need to absorb for files that are not submission targets. A +blanket "keep current names everywhere" policy gives up external interop +entirely. The chosen design splits along these two pressures. + +Two earlier ADRs already touch this surface: + +- [`loop-category-key-identity.md`](loop-category-key-identity.md) pins + loop-key naming on COMCIFS conventions. +- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + catalogues Python-vs-CIF category mismatches and chooses which side + should bend. + +This ADR closes the remaining gap on the **CIF side**. + +## Scope + +In scope: + +- A tiered category-and-item-name policy for the default save, split by + domain (structure / analysis / experiment). +- A new IUCr export path that produces a single journal-submission CIF + on demand, separate from the default save. +- ADP write-side single-tag emission and casing alignment in the + structure tier. +- Loop-tag style policy: dotted DDLm form universally on write, both + dotted and underscore forms accepted on read. +- The per-descriptor mechanism that wires both write paths (`iucr_name` + on `CifHandler`, category-level `IucrCategoryTransformer` for + structural reshapings). +- Multi-datablock layout in the IUCr export, including the `data_global` + publication-metadata block. + +Out of scope: + +- Python attribute renames. This ADR changes CIF emission only. + Cross-reference + [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + for Python-side decisions. +- Adding new CIF categories the project does not currently track + (`_chemical.*`, `_publ.*`, `_journal.*`) **for the default save**. The + IUCr export emits the publication-metadata categories per §2.3a with + `?` placeholders where the project has no source data. +- imgCIF (`cif_img.dic`); no raw image persistence path exists. +- Project-level singleton categories `_info.*`, `_chart.*`, `_table.*`, + `_verbosity.*` — out of scope here; see + `python-cif-category-correspondence`. + +## Design Philosophy: Tiered Default Save + Separate IUCr Export + +Each saved file lives in a directory whose name already scopes its +contents. `structures/.cif` is unambiguously structural; +`experiments/.cif` is unambiguously experimental; +`analysis/analysis.cif` is unambiguously analytic. The file path does +the disambiguation that a category prefix would otherwise carry. That +observation drives the policy: + +- **Structure tier** — align category and item names with IUCr verbatim + (with casing fixes). Crystallographic CIF names have decades of + literature backing; hand-editors recognise them. +- **Analysis tier** — keep all fit-output statistics under + topology-neutral `_fit_result.*` in `analysis/analysis.cif`, with + **item** names matching dictionary casing (uppercase R / wR / DOI, + etc.). The per-topology category split into `_refine_ls.*` / + `_pd_proc_ls.*` / `_reflns.*` happens only in the IUCr export, where + the experiment family is known per block. This sidesteps the + schema-choice problem for joint and sequential fits described in the + analysis-cif-fit-state ADR. Project-specific + minimizer/sampler/Bayesian scaffolding stays under the current + category names — file-scoped to `analysis/analysis.cif`, no namespace + prefix. +- **Experiment tier** — keep current UX-friendly names (`_instr.*`, + `_peak.*`, `_background.*`, `_expt_type.*`). The pdCIF + instrument/calibration model is awkward (radiation as a loop, TOF as a + coefficient loop indexed by integer `power`), and pdCIF has no + parametric peak-shape items at all. File path scopes them; no prefix + needed. +- **Reports** — a separate write path, `project.save(report=True)`, that + pulls live Python state and emits a single journal-submission CIF to + `reports/.cif`. This path applies all IUCr renames, + structural reshapings, multi-datablock layout, and project-extension + namespacing (`_easydiffraction_*`). Lives under the new + `project.report` facade slot (replaces the unimplemented + `project.summary` placeholder). **Export only — no round-trip.** + +## Current State + +Project CIF categories audited against `cif_core.dic` v3.4.0 and +`cif_pow.dic` v2.5.0. The "Default-save tier" column shows whether the +category changes in the default save; the "IUCr export" column shows the +dotted DDLm tag emitted under `project.save(report=True)`. + +| Category (current) | IUCr dictionary | Default-save tier | IUCr export (dotted DDLm) | +| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_cell.*` | core | Structure — unchanged | `_cell.length_a`, `_cell.angle_alpha`, etc. | +| `_atom_site.*` (most fields) | core | Structure — unchanged | `_atom_site.label`, `_atom_site.fract_x`, … | +| `_atom_site.adp_type` | core (`_atom_site.ADP_type`) | Structure — casing fix | `_atom_site.ADP_type` (uppercase ADP per dictionary). | +| `_atom_site.wyckoff_letter` | core (`_atom_site.Wyckoff_symbol`) | Structure — rename | `_atom_site.Wyckoff_symbol` (uppercase W, "symbol" not "letter"). | +| `_atom_site.B_iso_or_equiv` / `U_iso_or_equiv` | core | Structure — single-tag emit | `_atom_site.B_iso_or_equiv` xor `_atom_site.U_iso_or_equiv` per row, based on `_atom_site.ADP_type`. | +| `_atom_site_aniso.B_*` / `U_*` | core | Structure — single-tag emit | `_atom_site_aniso.B_*` xor `_atom_site_aniso.U_*` per row. | +| `_space_group.name_h_m` | core (`_space_group.name_H-M_alt`) | Structure — casing fix | `_space_group.name_H-M_alt`. | +| `_space_group.it_coordinate_system_code` | core (`_space_group.IT_coordinate_system_code`) | Structure — casing fix | `_space_group.IT_coordinate_system_code`. | +| symmetry operations | core (`_space_group_symop.*`) | (not emitted today) | `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop alongside the H-M name. | +| `_diffrn.ambient_temperature`, `ambient_pressure` | core | Experiment — unchanged | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`. | +| `_diffrn.ambient_magnetic_field`, `ambient_electric_field` | none | Experiment — unchanged | `_easydiffraction_diffrn.ambient_magnetic_field`, `…electric_field` (project extension). | +| `_refln.*` | core | (no default save under refln) | `_refln.*` reflections loop (column set differs by domain — see §2.3). | +| `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*`, `_pd_data.*` | pdCIF | Experiment — unchanged | `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*` profile-data loop (see §2.3). | +| `_pd_background.*` | pdCIF | Experiment — unchanged | `_pd_background.*`. | +| `_pd_phase_block.*` | pdCIF | Experiment — unchanged | `_pd_phase_block.*`. | +| `_sc_crystal_block.*` | community (no IUCr counterpart) | Experiment — unchanged | `_easydiffraction_sc_crystal_block.*` in IUCr export. | +| `_instr.wavelength` | core (`_diffrn_radiation_wavelength.value`) | Experiment — unchanged | `_diffrn_radiation_wavelength.{id, value, wt}` — single-row category for monochromatic; loop only for multi-λ. | +| `_instr.2theta_offset` | pdCIF (`_pd_calib.2theta_offset`) | Experiment — unchanged | `_pd_calib.2theta_offset`. | +| `_instr.2theta_bank`, `d_to_tof_*` | pdCIF (`_pd_calib_d_to_tof.*` loop) | Experiment — unchanged | Four-row loop `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`. | +| `_peak.*` (parametric profile shape) | none (pdCIF has no shape parameters) | Experiment — unchanged | `_easydiffraction_peak.*` + `_pd_proc_ls.profile_function` free-text descriptor. | +| `_extinction.*` | core (`_refine_ls.extinction_*` items) | Experiment — unchanged | `_easydiffraction_extinction.*` + dual emit `_refine_ls.extinction_{method,coef,expression}`. | +| `_excluded_region.*` | pdCIF (`_pd_proc.info_excluded_regions` free-text) | Experiment — unchanged | `_easydiffraction_excluded_region.*` + `_pd_proc.info_excluded_regions` free-text rendering. | +| `_expt_type.*` | none | Experiment — unchanged | `_easydiffraction_experiment_type.*`. | +| `_calculator.type`, `_minimizer.type` | none | Analysis — unchanged | Identification rolled into the `_easydiffraction_software.{framework, calculator, minimizer}` category; `_computing.structure_refinement` carries the same info as IUCr-standard free text. | +| `_minimizer.*` settings (tolerances, max_iter, …) | none | Analysis — unchanged | `_easydiffraction_minimizer.*` (settings only, separate from the identification triple). | +| `_fitting_mode.type`, `_background.type` | none | Analysis / Experiment — unchanged | `_easydiffraction_fitting_mode.type`, `_easydiffraction_background.type` selectors. | +| `_fit_result.reduced_chi_square`, `n_data_points`, `n_parameters` | core (`_refine_ls.*`) and pdCIF (`_pd_proc_ls.*`) | Analysis — unchanged (topology-neutral) | Shape-shifting per topology: see §1.2 and §3 transformers. | +| `_fit_result.*` (R-factors, counts, profile/background function) | core / pdCIF | Analysis — new fields under `_fit_result.*` | IUCr export remaps to per-topology `_refine_ls.*` / `_pd_proc_ls.*`; item names already match dictionary casing (§1.2). | +| `_fit_result.*` (Bayesian diagnostics, success, message, fitting_time, iterations, result_kind) | none | Analysis — unchanged | `_easydiffraction_fit_result.*`. | +| `_fit_parameter`, `_fit_parameter_correlation` | none / partial | Analysis — unchanged | `_easydiffraction_fit_parameter*` (no IUCr counterpart for per-parameter posterior). | +| `_alias`, `_constraint` | none | Analysis — unchanged | `_easydiffraction_alias*`, `_easydiffraction_constraint*`. | +| `_joint_fit`, `_sequential_fit*` | none | Analysis — unchanged | `_easydiffraction_joint_fit*`, `_easydiffraction_sequential_fit*`. | +| reflection-set aggregates | core (`_reflns.*`) | Analysis — new fields | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` (e.g. `'I>3\s(I)'`). | +| publication metadata | core (`_journal.*`, `_publ_author.*`, `_publ_contact_author.*`, `_audit.*`) | (not emitted today) | Emitted in `data_global` block per §2.3a with `?` placeholders. | +| analysis-stack identification | core (`_computing.structure_refinement`) | (not emitted today) | `_easydiffraction_software.{framework, calculator, minimizer}` triple + `_computing.structure_refinement` derived string in `data_global` (see §2.3a-i). | + +## Decision + +### 1. Three-tier default save + +#### 1.1 Structure tier — IUCr alignment + casing fixes + +In `structures/.cif`: + +- Rename `_atom_site.adp_type` → `_atom_site.ADP_type` (uppercase ADP). +- Rename `_atom_site.wyckoff_letter` → `_atom_site.Wyckoff_symbol` + (uppercase W, "symbol" not "letter"). The dictionary item + `_atom_site.Wyckoff_letter` does not exist; the "letter" form lives in + a different category (`_space_group_Wyckoff.letter`). +- Rename `_space_group.name_h_m` → `_space_group.name_H-M_alt` + (uppercase hyphenated H-M, with `_alt` suffix per dictionary). +- Rename `_space_group.it_coordinate_system_code` → + `_space_group.IT_coordinate_system_code` (uppercase IT). +- ADP single-tag emission per row (see §4). +- All other `_cell.*`, `_atom_site.*`, `_atom_site_aniso.*`, + `_space_group.*` items already match IUCr verbatim — unchanged. + +Python attribute names stay lowercase (`atom_site.adp_type`, +`atom_site.wyckoff_letter`, `space_group.name_h_m`, +`space_group.it_coordinate_system_code`). Only emitted CIF tags change. + +#### 1.2 Analysis tier — topology-neutral `_fit_result.*`, IUCr renaming on export only + +In `analysis/analysis.cif`: + +- **All fit-output statistics stay under topology-neutral + `_fit_result.*` in the default save.** The IUCr-side category split + into coreCIF `_refine_ls.*` (single-crystal) versus pdCIF + `_pd_proc_ls.*` (powder) happens **only** in the IUCr export (§2 / §3 + transformers). This avoids the deterministic- schema problem + reviewer-flagged for joint and sequential fits: one + `analysis/analysis.cif` can describe a refinement that spans multiple + experiments with different sample forms, so a per-experiment-driven + schema choice cannot be made at the project level. Topology-neutral + `_fit_result.*` round-trips cleanly under + [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md)'s single + common projection. +- Existing items keep their names verbatim + (`_fit_result.reduced_chi_square`, `_fit_result.n_data_points`, + `_fit_result.n_parameters`, …). The IUCr export remaps them to the + dictionary-canonical tags per topology (see §2 and §3). +- **New items added under `_fit_result.*`** using dictionary-canonical + _item names_ (with the project-side category prefix preserved): + - `_fit_result.R_factor_all`, `_fit_result.wR_factor_all` (uppercase R + / wR matching the coreCIF item names). + - `_fit_result.R_factor_gt`, `_fit_result.wR_factor_gt` + (observed-reflection subsets). + - `_fit_result.prof_R_factor`, `_fit_result.prof_wR_factor`, + `_fit_result.prof_wR_expected` (powder-only, derived from profile + residuals). + - `_fit_result.number_restraints`, `_fit_result.number_constraints` + (written only when positive). + - `_fit_result.profile_function`, `_fit_result.background_function` + (powder; free-text descriptors of the active peak and background + categories). + - `_fit_result.threshold_expression`, + `_fit_result.number_reflns_total`, `_fit_result.number_reflns_gt` — + required to make the `_gt` R-factor pair interpretable. Fields that + are not meaningful for a given experiment family (e.g., + `prof_R_factor` for a single-crystal-only refinement) are omitted + from `_fit_result.*`; the IUCr export omits them per block. Live + deterministic fit results may still carry runtime-only convergence + diagnostics such as `shift_over_su_max` and `shift_over_su_mean`, + but those are not part of the default `analysis/analysis.cif` + fit-result projection. +- Bayesian diagnostics, success/message/iterations/fitting*time, + `result_kind`, `point_estimate_name`, fit-parameter posterior + summaries, and the `_alias` / `_constraint` / `_joint_fit` / + `\_sequential_fit*`registries — **stay under their current category names**. File-scoping to`analysis/analysis.cif`carries the disambiguation; no`\_easydiffraction\*\*` + prefix is added in the default save. +- The `_minimizer.*`, `_fitting_mode.*`, `_calculator.*` selectors stay + under their current names for the same reason. + +The dictionary-canonical tag _form_ (uppercase R / wR / DOI, etc.) is +preserved in the _item_ names under `_fit_result.*`. Only the _category_ +prefix changes between default save (`_fit_result.*`) and IUCr export +(`_refine_ls.*` / `_pd_proc_ls.*` / `_reflns.*` per block). + +#### 1.3 Experiment tier — no changes in the default save + +In `experiments/.cif`: keep every current category and item name. +Specifically: + +- `_instr.*`, `_peak.*`, `_background.*` / `_pd_background.*`, + `_pd_phase_block.*`, `_sc_crystal_block.*`, `_extinction.*`, + `_excluded_region.*`, `_expt_type.*`, `_diffrn.*`, + `_pd_meas/proc/calc/data.*`, `_refln.*` — all unchanged. +- Wavelength stays the single scalar `_instr.wavelength`. TOF + calibration stays the four scalar items + `_instr.d_to_tof_{offset, linear, quad, recip}`. The structural + reshapings happen only in the IUCr export (§2). +- Parametric profile shape (`_peak.*` Caglioti / Lorentzian / FCJ / TOF + coefficients) stays under `_peak.*`. + +### 2. Reports — IUCr submission CIF + +#### 2.1 API + +```python +project.save() # regular project save only +project.save(report=True) # regular save + reports/.cif +project.report.save() # write reports only, no regular save +project.report.check() # validate reports (see §2.5) +``` + +`project.summary` (currently an unimplemented placeholder) is removed +and replaced by `project.report` — a new facade slot that owns the +journal-submission CIF generation and validation. The slot is named +generically because the same path can host additional report types in +the future (mmCIF export, figure bundles, etc.); the IUCr CIF is the +only kind shipped today. + +#### 2.2 Output location + +A **single CIF file** at `reports/.cif` inside the project +root. One file per project, regardless of how many structures or +experiments live in it; the published IUCr submission convention is "one +CIF per article, multiple data blocks inside" (corroborated by 10/10 +example files in the corpus). + +```text +/ + project.cif + structures/ + phase1.cif + phase2.cif + experiments/ + pd_neutron.cif + pd_xray.cif + analysis/ + analysis.cif + reports/ # written by save(report=True) + .cif # single multi-block IUCr CIF +``` + +##### Worked layout examples + +Concrete project layouts for the four topology types covered in §2.3. +Default-save files are one-per-object as today; the IUCr export +collapses to a single file with topology-driven data blocks. Inline +comments name the categories inside each block. + +**Example A — Single-crystal, single structure (single experiment).** + +```text +quartz_sc/ + project.cif + structures/ + quartz.cif # _cell.*, _atom_site.* (ADP_type, Wyckoff_symbol) + experiments/ + xray_sc.cif # _instr.*, _peak.*, _diffrn.* + analysis/ + analysis.cif # _fit_result.* (topology-neutral: reduced_chi_square, + # R_factor_*, wR_factor_*, n_data_points, n_parameters, + # plus Bayesian / non-IUCr fields) + # _easydiffraction_minimizer.* (settings) + reports/ + quartz_sc.cif # data_global — _journal.*, _publ_*, _audit.*, + # _easydiffraction_software.{framework, + # calculator, minimizer}, + # _computing.structure_refinement, + # _chemical_formula.* + # data_quartz — _cell.*, _atom_site.*, _atom_site_aniso.*, + # _space_group.*, _space_group_symop.* loop, + # _diffrn.*, _diffrn_radiation_wavelength.*, + # _refine_ls.*, _reflns.*, + # _refln.* loop (F², include_status), + # _easydiffraction_extinction.* + + # _refine_ls.extinction_* dual emit +``` + +**Example B — Powder Rietveld, single phase, single experiment.** + +```text +mgo_rietveld/ + project.cif + structures/ + mgo.cif + experiments/ + npd.cif # neutron powder, CWL + analysis/ + analysis.cif + reports/ + mgo_rietveld.cif # data_global — publication metadata, software, _chemical_formula + # data_mgo_rietveld_overall — _pd_proc_ls.prof_R_factor, + # .prof_wR_factor, + # .prof_wR_expected, + # .profile_function, + # .background_function, + # _refine_ls.number_parameters, + # _pd_block_id cross-refs + # data_mgo_rietveld_phase_0 — MgO structure + # data_mgo_rietveld_pwd_0 — _pd_meas.* profile loop + # (_2theta_scan, intensity_total, + # _pd_calc.intensity_total, + # _pd_proc.intensity_bkg_calc, + # _pd_proc_ls.weight), + # _refln.* powder reflections loop +``` + +**Example C — Joint Rietveld, multi-experiment (neutron + X-ray).** + +```text +co2sio4/ + project.cif + structures/ + co2sio4.cif + experiments/ + npd_300K.cif + xrd_300K.cif + analysis/ + analysis.cif # _joint_fit weights + reports/ + co2sio4.cif # data_global — publication metadata + # data_co2sio4_overall — combined refinement stats + # data_co2sio4_phase_0 — Co2SiO4 structure + # data_co2sio4_pwd_0 — NPD pattern, + # _pd_block_diffractogram_id='npd_300K' + # data_co2sio4_pwd_1 — XRD pattern, + # _pd_block_diffractogram_id='xrd_300K' +``` + +**Example D — Sequential fit, multi-temperature TOF Rietveld.** + +```text +co2sio4_t_series/ + project.cif + structures/ + co2sio4.cif + experiments/ + tof_5K.cif + tof_100K.cif + tof_165K.cif + tof_200K.cif + analysis/ + analysis.cif # _sequential_fit configuration + reports/ + co2sio4_t_series.cif # data_global — publication metadata + # data_co2sio4_t_series_overall + # data_co2sio4_t_series_phase_0 + # data_co2sio4_t_series_pwd_0 — TOF 5K, + # _pd_meas.time_of_flight, + # _pd_calib_d_to_tof loop + # data_co2sio4_t_series_pwd_1 — TOF 100K + # data_co2sio4_t_series_pwd_2 — TOF 165K + # data_co2sio4_t_series_pwd_3 — TOF 200K +``` + +#### 2.3 Multi-datablock layout inside the export file + +**Every export file starts with a `data_global` block carrying +publication metadata** (§2.3a). Subsequent blocks depend on analysis +topology. Block content uses dotted DDLm form throughout. The +single-block-name rule is uniform across topologies; topology-specific +GSAS-II-style suffix conventions seen in some example files (e.g. +`data__publ`, `data__overall`) are folded into +`data_global` for the publication header and `data__overall` +for refinement metadata, leaving no ambiguity about where the +journal-required publication items live. + +- **Single-crystal, single structure (single experiment).** + `data_global` + `data_` (or `data_I` if no name is set). + Pattern in `bal5004.cif`, `bp5083.cif`, `ks5497.cif`, `ra5167.cif`: + `data_global + data_I`. + +- **Single-crystal, multiple structures or temperatures.** + `data_global` + one block per structure or per temperature. Pattern in + `bp5014.cif`: `data_global + data_300K + data_55K + data_2point5K`. + +- **Powder Rietveld (single or multi-experiment, single or + multi-phase).** GSAS-II-style block split, with the publication block + renamed to `data_global` per the invariant above: + - `data_global` (publication metadata, per §2.3a), + - `data__overall` (refinement-level metadata — Rietveld + R-factors, profile/background function descriptors, parameter + counts), + - `data__phase_N` (one per phase — structural data per + `_pd_phase_block.id`), + - `data__pwd_N` (one per diffraction pattern — measurement + metadata, profile data loop, reflections loop). + + This deviates from the `data__publ` GSAS-II convention seen + in `hb8206.cif`; the deviation buys a uniform rule across + single-crystal and powder exports and matches the single-crystal + corpus (`bal5004`, etc.) which uses `data_global` universally. + +- **Multi-experiment joint Rietveld.** Same shape as the + single-experiment Rietveld block split above, with additional `_pwd_N` + blocks per pattern, all cross-referenced via + `_pd_block_diffractogram_id` and `_pd_block_id` pipe-delimited + identifiers (e.g. `2025-12-06T14:46|binimetinib_3|noname|PubInfo`, + format mirrored from `hb8206.cif`). + +- **Sequential fit.** One file per step is **not** the IUCr convention; + sequential refinements emit one `data__pwd_N` block per step + inside the same `reports/.cif`. Natural sequential ordering + matches the multi-pattern Rietveld pattern above. + +#### 2.3a `data_global` block content + +Items below are all defined in `cif_core.dic` v3.4.0; emit values where +the project has source data, otherwise `?`. + +- `_audit.creation_method 'EasyDiffraction '`, + `_audit.creation_date `. +- `_computing.structure_refinement` (single string concatenating the + framework + calculator + minimizer names and versions, e.g. + `'EasyDiffraction 0.17.0 with lmfit 1.0.0 minimizer and cryspy 1.2.3 calculator'`). + coreCIF standard channel for advertising the analysis-software stack + to IUCr-aware tooling. +- `_easydiffraction_software.*` triple holding the same three roles in + structured form (see §2.3a-i below). +- `_journal.*` placeholders, written as `?` when the project has no + source data: `_journal.name_full`, `_journal.year`, `_journal.volume`, + `_journal.issue`, `_journal.page_first`, `_journal.page_last`, + `_journal.paper_category`, `_journal.paper_DOI`, + `_journal.coden_ASTM`, `_journal.suppl_publ_number`. +- `_journal_date.*` placeholders: `_journal_date.accepted`, + `_journal_date.from_coeditor`, `_journal_date.printers_final`, etc. +- `_journal_coeditor.*` placeholders: `_journal_coeditor.code`, + `_journal_coeditor.name`, `_journal_coeditor.notes`. +- `_publ_contact_author.*` placeholders: `_publ_contact_author.name`, + `_publ_contact_author.address`, `_publ_contact_author.email`, + `_publ_contact_author.phone`, `_publ_contact_author.id_ORCID`, + `_publ_contact_author.id_IUCr`. +- `_publ_author.*` loop placeholders (`_publ_author.name`, + `_publ_author.address`, `_publ_author.footnote`, + `_publ_author.id_ORCID`, `_publ_author.id_IUCr`). +- `_publ_body.*` for section content (`_publ_body.title`, + `_publ_body.contents`). +- `_chemical_formula.*` chemistry summary derived from atom-site data + where possible: `_chemical_formula.sum`, `_chemical_formula.moiety`, + `_chemical_formula.weight`, `_chemical_formula.IUPAC` (uppercase IUPAC + per dictionary). + +User-supplied publication metadata override (`publ_info.json` or +similar) is deferred — see Deferred Work. + +#### 2.3a-i `_easydiffraction_software` framework + +The IUCr submission needs to identify the analysis stack. The project +emits one structured category in `data_global` carrying three role-keyed +strings: + +``` +_easydiffraction_software.framework 'EasyDiffraction 0.17.0' +_easydiffraction_software.calculator 'cryspy 1.2.3' +_easydiffraction_software.minimizer 'lmfit 1.0.0' +``` + +- `_easydiffraction_software.framework` — EasyDiffraction itself, the + orchestrating analysis software, with version. +- `_easydiffraction_software.calculator` — the active calculation + backend (cryspy, crysfml, pdffit2) with version. +- `_easydiffraction_software.minimizer` — the active minimizer (lmfit, + scipy-lstsq, dfo-ls, emcee, …) with version. Bayesian sampler runs use + the sampler name and version here. + +The same three values are concatenated into the +`_computing.structure_refinement` free-text string for IUCr-tooling +compatibility (publCIF / checkCIF key on `_computing.*`, not on the +project extension). + +The existing `_easydiffraction_minimizer.*` category in +`analysis/analysis.cif` (default save) keeps its role as the +**settings** container — convergence tolerances, max iteration counts, +sampler chain lengths, etc. — and is also emitted as +`_easydiffraction_minimizer.*` in the IUCr export, separate from the +identification triple above. + +#### 2.3b Structure-block content (per-block) + +For each `data_` (single-crystal) or `data__phase_N` +(powder Rietveld) block: + +- `_chemical_formula.{moiety, sum, weight, IUPAC}` summary. +- `_cell.*` (`length_a`, `angle_alpha`, `volume`, + `measurement_temperature`, etc.). +- `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`, + `_space_group.crystal_system`, plus the explicit + `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop + alongside the H-M name. +- `_diffrn.*` (instrument, radiation, measurement conditions). + Wavelength as the `_diffrn_radiation_wavelength` category (single-row + category form for monochromatic, loop form for multi-λ — see §3 + transformer). +- `_exptl_crystal.*` if the project tracks crystal-specimen metadata + (currently it does not — deferred work). +- `_atom_site.*` loop with `_atom_site.label`, `_atom_site.type_symbol`, + `_atom_site.fract_x/y/z`, `_atom_site.occupancy`, + `_atom_site.ADP_type`, `_atom_site.B_iso_or_equiv` xor + `_atom_site.U_iso_or_equiv` per row, `_atom_site.Wyckoff_symbol`. +- `_atom_site_aniso.*` loop (when anisotropic ADPs present), emitting + `B_*` xor `U_*` family per row. +- `_refine_ls.*` (single-crystal) or `_pd_proc_ls.*` (powder) refinement + statistics. +- `_reflns.number_total`, `_reflns.number_gt`, + `_reflns.threshold_expression`. + +#### 2.3c Single-crystal reflections loop + +Column set (DDLm dotted form): + +``` +loop_ +_refln.index_h +_refln.index_k +_refln.index_l +_refln.F_squared_meas +_refln.F_squared_calc +_refln.F_squared_meas_su +_refln.include_status +``` + +Column set chosen from `cif_core.dic` (the dictionary defines +`_refln.include_status` for marking observed reflections; the corpus +form `_refln.observed_status` is **not** in the current dictionary and +is treated as outdated). The `_su` suffix follows DDLm convention; the +parenthesised CIF uncertainty syntax remains the preferred numeric +encoding per `free-flag-cif-encoding.md`, so `_refln.F_squared_meas_su` +is emitted only when a paired-value column is needed. + +#### 2.3d Powder reflections loop + +``` +loop_ +_refln.index_h +_refln.index_k +_refln.index_l +_refln.F_squared_meas +_refln.F_squared_calc +_refln.phase_calc +_refln.d_spacing +``` + +Column set adapted from the corpus content (`bal5001.cif`, `hb8206.cif`) +with tag form taken from `cif_core.dic`. + +#### 2.3e Powder profile-data loop + +``` +loop_ +_pd_meas.2theta_scan +_pd_meas.intensity_total +_pd_calc.intensity_total +_pd_proc.intensity_bkg_calc +_pd_proc_ls.weight +``` + +For TOF experiments, the `_pd_meas.2theta_scan` column is replaced by +`_pd_meas.time_of_flight`. Verified against `bal5001.cif` (content set; +tag form follows `cif_pow.dic`). + +#### 2.3f `data__overall` block (Rietveld only) + +For powder Rietveld files, an `_overall` block carries refinement-level +metadata that applies across all phases and patterns: + +- `_pd_calc.method 'Rietveld Refinement'`. +- `_pd_proc_ls.prof_R_factor`, `_pd_proc_ls.prof_wR_factor`, + `_pd_proc_ls.prof_wR_expected`. +- `_pd_proc_ls.profile_function`, `_pd_proc_ls.background_function` + (free-text descriptors). +- `_pd_proc_ls.pref_orient_corr` (when preferred-orientation correction + is applied). +- `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, + `_refine_ls.number_constraints`. +- `_pd_block_id` pipe-delimited cross-reference values pointing to the + phase and pattern blocks. + +#### 2.3g `data__pwd_N` block (Rietveld only — constant wavelength) + +For each constant-wavelength (CWL) diffraction pattern: + +- `_pd_meas.*` measurement metadata (`_pd_meas.scan_method`, + `_pd_meas.2theta_range_min/max/inc`, `_pd_meas.number_of_points`, + `_pd_meas.datetime_initiated`, + `_pd_meas.info_author_{name, email, phone}` placeholders). +- `_diffrn.*` and `_diffrn_radiation_wavelength.*` (radiation type, + probe, wavelength). +- `_pd_proc.2theta_range_min/max/inc`, `_pd_proc.info_data_reduction`, + `_pd_proc.info_datetime`, `_pd_proc.info_excluded_regions`. +- `_pd_proc_ls.*` profile-fit R-factors for this pattern. +- The `_pd_meas.*` profile-data loop (§2.3e). +- The `_refln.*` reflections loop (§2.3d). + +#### 2.3h `data__pwd_N` block (Rietveld only — TOF) + +For time-of-flight (TOF) diffraction patterns the block has the same +shape as §2.3g, with three TOF-specific substitutions — **all defined in +`cif_pow.dic` v2.5.0**, no project extensions needed for the standard +powder TOF surface: + +- Measurement x-axis: `_pd_meas.time_of_flight` (with + `_pd_meas.time_of_flight_su` companion when paired-value emission is + needed). Replaces the `_pd_meas.2theta_scan` column in the + profile-data loop. +- d-spacing → TOF calibration: the four-row + `_pd_calib_d_to_tof.{id, coeff, coeff_su, power, diffractogram_id}` + loop materialised by the §3 transformer. The dictionary defines the + equation as `TOF = Σ c_i · d^(p_i)` (`_pd_calib_d_to_tof.coeff` and + `_pd_calib_d_to_tof.power`, summed over rows; cif_pow.dic lines 2429 + ff.). The `_pd_calib_d_to_tof.id` column accepts arbitrary codes per + the dictionary (its own example uses `0`, `DIFC`, `t2`); the project + uses the EasyDiffraction attribute names verbatim: + + ``` + loop_ + _pd_calib_d_to_tof.id + _pd_calib_d_to_tof.power + _pd_calib_d_to_tof.coeff + _pd_calib_d_to_tof.coeff_su + _pd_calib_d_to_tof.diffractogram_id + offset 0 + linear 1 + quad 2 + recip -1 + ``` + + Rows with a zero coefficient may be omitted. Units are determined + per-row by `power` (μs at power 0, μs/Å at power 1, μs/Ų at power 2, + Å/μs at power −1) per the dictionary's `_method.expression` block on + `_pd_calib_d_to_tof.coeff`. + +- Profile-data loop for TOF: + + ``` + loop_ + _pd_meas.time_of_flight + _pd_meas.intensity_total + _pd_calc.intensity_total + _pd_proc.intensity_bkg_calc + _pd_proc_ls.weight + ``` + + Same columns as §2.3e except the x-axis. The `_diffrn.*` and + `_pd_meas.scan_method` items advertise the TOF nature for external + readers that do not key off the column name alone. + +The richer +`_pd_calib_xcoord.{actual_time_of_flight, nominal_time_of_flight, …}` +calibration pair (`cif_pow.dic` lines 3881 ff., 4167 ff.) is **not** +emitted in the first pass — the project does not currently track +actual-vs-nominal TOF calibration distinct from the polynomial +coefficients. Flagged as deferred work. + +#### 2.4 Formatting (separate IUCr writer) + +The IUCr writer pass differs from the default writer: + +- Dotted DDLm item form (`_atom_site.label`) — same as the default save. + The reference dictionaries declare every item in dotted form; the + corpus' DDL1 underscore usage is treated as outdated tooling output, + not a target convention. +- Blank line between every category, and between a category and a + following loop. +- `# ----
----` header before each logical group within a + block (chemical metadata, cell, space group, symmetry operations, + diffraction, atoms, ADP, refinement, reflections / profile data, + project extensions). +- Block separator + `#=====================================================` between + `data_*` blocks. +- Loop columns left-aligned to per-column widths; loop body lines + indented two spaces. +- 80-char wrap on long string values per CIF spec. +- Numeric `_su` always written via the parenthesised CIF uncertainty + syntax (e.g. `5.4307(2)`); `_su` companion items are not emitted as + separate fields. Matches the existing project encoding from + `free-flag-cif-encoding.md`. +- Project-extension `_easydiffraction_*` categories grouped at the end + of each block under a `# ---- EasyDiffraction project extensions ----` + header. + +#### 2.5 Submission-side validation + +`project.report.check()` runs the generated `reports/.cif` +through `gemmi` (already a project dependency per `pyproject.toml`) for +dictionary-compliance validation before submission. + +```python +project.report.check() # validate reports/.cif +project.save(report=True, check=True) # save + validate in one step +``` + +Validation checks performed by `gemmi`: + +- Every emitted tag exists in `cif_core.dic` or `cif_pow.dic` (the + shipped reference dictionaries, or fresh copies fetched on demand). + Unknown tags outside the project's `_easydiffraction_*` namespace + produce a warning. +- Value types match the dictionary's `_type.contents` declaration (Real, + Integer, Code, Text, …). +- Required category keys (`_category_key.name` per `_pd_calib_d_to_tof`, + `_atom_site`, etc.) are present in every loop row. +- Loop columns share the same parent category. +- DDLm dotted form is well-formed; underscore-form aliases resolve + correctly. + +Validation does **not** cover: + +- Crystallographic sanity checks (bond lengths, void volumes, density + plausibility, missed-symmetry detection, anisotropic-ADP + positive-definiteness). These need a full `checkCIF` implementation, + which `gemmi` does not provide. Treat `project.report.check()` as a + "spec compliance" pass, not a "scientific sanity" pass — a separate + IUCr-server upload remains the final check before submission. +- Verifying that `?` placeholders in `_journal.*` / `_publ_*` have been + filled in by the user (those are valid CIF; the project cannot decide + which are mandatory per journal). Flagged as a separate concern. + +The `_easydiffraction_*` project-extension namespace is excluded from +the unknown-tag warning by passing `gemmi`'s validator a prefix-skip +list. + +### 3. Handler mechanism — `iucr_name` + `IucrCategoryTransformer` + +Both write paths read the same in-memory `Parameter` / +`StringDescriptor` / `NumericDescriptor` objects. Drift between default +save and IUCr export is prevented by two complementary mechanisms. + +**Per-field — `iucr_name: str | None` on `CifHandler`.** Singular, +chosen for consistency with the existing `names: list[str]`. The +exporter resolves the IUCr-side tag as `iucr_name` when set, otherwise +falls back to `names[0]`. Both forms are dotted DDLm. + +```python +# Structure — casing differs from default save +self._adp_type = StringDescriptor( + name='adp_type', + cif_handler=CifHandler( + names=['_atom_site.adp_type'], + iucr_name='_atom_site.ADP_type', + ), +) + +# Analysis — default already matches IUCr; iucr_name omitted +self._goodness_of_fit = Parameter( + name='reduced_chi_square', + cif_handler=CifHandler( + names=['_refine_ls.goodness_of_fit_all'], + # exporter falls back to names[0] + ), +) + +# Project extension — IUCr export uses _easydiffraction_* prefix +self._fitting_time = Parameter( + name='fitting_time', + cif_handler=CifHandler( + names=['_fit_result.fitting_time'], + iucr_name='_easydiffraction_fit_result.fitting_time', + ), +) +``` + +The mechanism scales: future export targets (mmCIF, journal dialects) +get sibling fields (`mmcif_name`, etc.) without rewriting existing +handlers. There is no clever prefix-substitution rule — explicit beats +clever. + +Per-experiment-family dual mapping (e.g., `fit_result.n_data_points` +mapping to `_refine_ls.number_reflns` for single-crystal but +`_pd_proc.number_of_points` for powder) is handled at the +category-transformer level (below), not by promoting `iucr_name` to a +list. The per-field handler stays simple. + +**Category-level — `IucrCategoryTransformer` subclasses for structural +reshaping.** A small number of items don't rename, they restructure: + +- **Wavelength** — single-row + `_diffrn_radiation_wavelength.{id, value, wt}` for monochromatic + radiation (the common case); full loop form when multiple wavelengths + are tracked. +- **TOF calibration** — four scalar Python parameters + (`d_to_tof_offset`, `d_to_tof_linear`, `d_to_tof_quad`, + `d_to_tof_recip`) materialise as a four-row + `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}` + loop. Per cif_pow.dic the equation is `TOF = Σ c_i · d^(p_i)`; the + rows use the EasyDiffraction attribute names as `id` codes (`offset`, + `linear`, `quad`, `recip`) with corresponding `power = 0, 1, 2, -1`. + Full row layout in §2.3h. +- **Range-form excluded regions** — free-text + `_pd_proc.info_excluded_regions` rendering of the range list. +- **Symmetry operations** — `_space_group_symop.*` loop derived from the + active space group (no Python-side persistence of symop strings + today). +- **Extinction (single-crystal)** — the project's + `_easydiffraction_extinction.{type, model, mosaicity, radius}` + category is **also** emitted as the coreCIF `_refine_ls.extinction_*` + triple in single-crystal IUCr export blocks. The mapping is direct + because the dictionary text for `_refine_ls.extinction_method` defines + it as a free-text descriptor that already enumerates the + Becker-Coppens type 1 / type 2 / mixed, Gaussian / Lorentzian, + isotropic / anisotropic taxonomy — exactly what the project's `type` + + `model` selectors represent. Concrete mapping: + + | Project field | IUCr emit | + | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | + | `extinction.type = 'becker_coppens'`, `extinction.model = 'gaussian_isotropic_type1'` | `_refine_ls.extinction_method 'Becker-Coppens type 1 Gaussian isotropic'` | + | `extinction.type = 'zachariasen'` | `_refine_ls.extinction_method 'Zachariasen'` | + | `extinction.mosaicity` (BC type 1 or Zachariasen) | `_refine_ls.extinction_coef ` | + | `extinction.radius` (BC type 2) | `_refine_ls.extinction_coef ` | + | BC mixed (both `mosaicity` and `radius` present) | `_refine.special_details ''` per dictionary text | + + The transformer reads the active `_easydiffraction_extinction.*` + values, picks the right coefficient channel based on `type` and + `model`, and falls back to `_refine.special_details` when the + Becker-Coppens "mixed" case is detected. The + `_easydiffraction_extinction.*` block is emitted alongside (not + instead of) the standard items, so the full project-side detail + survives round-trip through any tool that reads `_easydiffraction_*` + extensions while standard tools see the coreCIF triple. + +These cannot be expressed as `iucr_name`; the unit of transformation is +a category, not a field. They live in the IUCr exporter as +`IucrCategoryTransformer` subclasses, registered alongside the existing +`CategoryItem` subclasses. + +### 4. ADP tags — single-tag emission on write + +Both `_atom_site_aniso.B_ii` and `_atom_site_aniso.U_ii` exist in +coreCIF, as do `_atom_site.B_iso_or_equiv` and +`_atom_site.U_iso_or_equiv`. The dictionary expects exactly one family +per file, declared by `_atom_site.ADP_type`. Policy applies to **both** +default save and IUCr export: + +- **Read**: accept either tag family. Unchanged. +- **Write**: emit the tag matching `atom_site.ADP_type` for that row; + omit the other. + +The choice is per-row based on `ADP_type`, not a project-wide default. +The [`type-neutral-adp-parameters.md`](type-neutral-adp-parameters.md) +Python contract is unchanged. + +The writer no longer propagates one file-wide B/U convention across all +atom sites before serialisation. If a structure contains both +B-convention and U-convention atoms, the emitted CIF contains one +`_atom_site_aniso.B_*` loop and one `_atom_site_aniso.U_*` loop, each +containing only the rows whose `ADP_type` matches that family. + +### 5. Loop-tag style — dotted DDLm on write, dual-name on read + +Both reference dictionaries declare every item in dotted DDLm form and +record the legacy DDL1 underscore form as `_alias.definition_id` (787 in +coreCIF, 180 in pdCIF). The dictionaries are the spec; corpus example +files often lag the spec because they are produced by tooling (GSAS-II, +Jana2006, SHELX, etc.) that has not yet caught up with the DDLm +conversion. + +Policy: + +- **Write — dotted DDLm form universally** for both the default save and + the IUCr export. Matches the dictionaries' canonical identifiers and + the project's current write behaviour. +- **Read — accept dotted and underscore form** for every IUCr-aligned + category, using the dictionaries' `_alias.definition_id` table as the + source of truth. The project already does this for + `_pd_background.line_segment_X` / `_pd_background_line_segment_X`; + extend the same policy to every IUCr-aligned category. + +## Consequences + +### Positive + +- Day-to-day saved files keep current UX (no Caglioti coefficients + hidden inside loops, no awkward `_diffrn_radiation_wavelength` loop + for what's morally a scalar, no `_pd_calib_d_to_tof.power` integer + rows for users to figure out). +- Structure CIFs (default save) become directly recognisable to + crystallographers reading or hand-editing them — names match the + literature. +- Analysis CIFs (default save) use dictionary-canonical _item_ names for + fit statistics (uppercase R / wR, etc.) under the topology-neutral + `_fit_result.*` category, so per-field identifiers are immediately + recognisable to scientists familiar with `_refine_ls.*` / + `_pd_proc_ls.*` from Rietveld publications; the IUCr export carries + the matching dictionary-canonical category prefixes per topology. +- IUCr submission becomes a single command, with no manual editing + required: `project.save(report=True)` produces an upload-ready file at + `reports/.cif` matching the multi-datablock publication + convention. +- Publication-metadata placeholders are emitted as `?` in `data_global` + so users know where to fill in journal-required info before + submission. +- External IUCr tooling (publCIF, checkCIF, pdCIFplotter, + journal-submission pipelines) consumes the submission file cleanly; + the day-to-day saved files are not a tooling target. +- `_easydiffraction_*` prefix appears only in the IUCr export, where the + explicit namespacing aids journal reviewers. It does not bloat + day-to-day CIFs. +- Drift between default and IUCr write paths is structurally prevented: + both paths read the same `Parameter` objects through the same + `CifHandler` and emit the same DDLm dotted form. + +### Trade-offs + +- Two write paths to implement and test. Single source of truth (the + in-memory `Parameter` objects) keeps drift bounded; the per-field + `iucr_name` plus per-category `IucrCategoryTransformer` mechanism is + the testable seam. +- Powder Rietveld IUCr CIFs are large because measured and calculated + profile data is embedded. Acceptable for journal submission; the + format is what reviewers expect. Largest inspected example: + `hb8169.cif` at 50K lines (DDL1 form; DDLm form would be of comparable + size). +- IUCr export is one-way. A user who hand-edits a file in `reports/` + loses those edits on the next `project.save(report=True)`. Documented + as such; treat `reports/` as generated output. +- Some external tooling chains (publCIF, journal in-house scripts) may + still expect DDL1 underscore form. The dotted DDLm form is the + dictionary spec; if real submissions surface a problem, a downstream + conversion option can be added on request. Not pre-emptively built in. + +### ADRs amended by this ADR + +- [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) — new + IUCr-named fields added under `_fit_result.*` in the default save + (R-factors, positive restraint/constraint counts, profile/background + function descriptors, reflns aggregates). `_fit_result.*` stays + topology-neutral in `analysis/analysis.cif`; per-topology renaming to + `_refine_ls.*` / `_pd_proc_ls.*` happens only in the IUCr export + (§1.2, §3 transformers). +- [`minimizer-input-output-split.md`](minimizer-input-output-split.md) — + `_fit_result.*` examples updated for the new fields. +- [`project-facade-and-persistence.md`](project-facade-and-persistence.md) + — `project.summary` facade slot is removed and replaced by + `project.report`. `summary.cif` is no longer written by default + `Project.save()`; the slot is repurposed for IUCr / journal report + generation in `reports/.cif` (see §2). The unimplemented + `summary_to_cif()` placeholder code path + ([`project.py:464`](../../../../src/easydiffraction/project/project.py)) + is removed as part of the implementation plan; no summary content + survives the transition because nothing was being written there in the + first place. +- [`help-discoverability.md`](help-discoverability.md) — + `project.summary.help()` is removed from the documented help surface + and replaced by `project.report.help()` (same responsibilities, new + slot name). All other entries in the help-surface table are + unaffected. + +## Open Questions + +(None blocking. Dictionary-side ambiguities have all been resolved +against `cif_core.dic` v3.4.0 / `cif_pow.dic` v2.5.0. The §2.5 gemmi +pass surfaces any remaining spec-compliance issue at generate-time, so +the ADR no longer relies on speculation about real-world tooling +behaviour.) + +## Alternatives Considered + +### A. Keep all current tags as-is + +Smallest diff. Saved CIFs stay self-contained but cannot be consumed by +external IUCr tooling, and journal submission requires manual +conversion. Defensible only if external CIF interop is never a goal. + +### B. Align everything by default (no separate IUCr export) + +The previous broad-rewrite extension. Maximises external interop but +pays the UX cost on every saved file — TOF coefficient loops, wavelength +category form for what's morally a scalar, `_easydiffraction_*` prefixes +in `analysis/analysis.cif`. Replaced by the tiered design above. + +### C. Adopt IUCr fit-output names only (the original fit-output-only ADR) + +Fixes the most visible gap (`_fit_result.*`) but leaves the instrument, +calibration, casing, loop-style, ADP write-side, and journal-submission +decisions unstated. Preserved here as §1.2. + +### D. Two write paths, no shared handler mechanism + +Implement the IUCr export as a fully separate writer that re-implements +every tag mapping. Doubled maintenance, guaranteed drift. Rejected. + +### E. Round-trip-capable IUCr files + +The IUCr export could be the source of truth and the default saved files +could be derived from it. Requires retaining `_easydiffraction_*` +extension data through the IUCr writer and parsing it back on load. Adds +round-trip surface area for no day-to-day benefit. Rejected explicitly: +**IUCr export is one-way**. + +### F. Multiple IUCr files (one per refined dataset) + +The earlier version of §2 proposed `reports/.cif` files — one +per refinement unit. The IUCr submission convention is one file per +article with multiple data blocks inside (consistent with the corpus). +Rejected. + +### G. Emit DDL1 underscore form in the IUCr export + +An earlier revision proposed switching the IUCr export to DDL1 +underscore form because every inspected corpus file used it. Rejected: +the COMCIFS reference dictionaries are the authoritative spec, and they +declare every item in dotted DDLm form. Corpus files frequently lag the +spec because the tooling that produced them (GSAS-II, Jana2006, SHELX, +etc.) has not yet caught up with the DDLm conversion; their tag style is +**not** a target convention. If a specific journal portal turns out to +reject DDLm input, the dual-style fallback in "Open Questions" covers +it. + +## Deferred Work + +- **Publication-metadata override hook.** A user-supplied + `reports/publ_info.json` (or `publ_info.toml`) read by + `project.save(report=True)` to replace the `?` placeholders in + `data_global` (`_journal.*`, `_publ_*`, `_publ_author.*` loop + entries). Out of scope for the first pass; revisit once the IUCr + export is shipping and users have feedback on workflow friction. +- **Crystallographic sanity validation.** The §2.5 validator covers spec + compliance only. A future pass could integrate IUCr's web checkCIF + (HTTP POST to the checkCIF endpoint) or bundle a local subset of its + sanity checks (bond-length plausibility, void detection, + missed-symmetry, anisotropic-ADP positive-definiteness). Treated as a + separate concern from dictionary validation. +- **Richer TOF calibration.** `_pd_calib_xcoord.actual_time_of_flight` / + `nominal_time_of_flight` paired calibration (cif_pow.dic lines 3881 + ff., 4167 ff.) for instruments that distinguish actual vs nominal TOF. + EasyDiffraction tracks only the polynomial coefficients today. +- **`_atom_type_scat_*` Cromer-Mann and neutron scattering-length + tables.** Required by GSAS-II-style files (`hb8206.cif`) for + self-contained reflection calculation, but EasyDiffraction does not + track these today. +- **`_exptl_crystal.*` single-crystal-specimen metadata** (size, shape, + density, etc.). The project has no source data for these fields; emit + as `?` placeholders or skip entirely. +- **`_audit.*` extended audit trail** (`_audit.update_record`, + `_audit.block_DOI`). +- **mmCIF / other macromolecular-targeting export.** Same handler + mechanism (`mmcif_name` sibling field) but a different exporter. Not + on the roadmap. +- **Default-save `_chemical_formula.*` derivation** from `_atom_site` + rows. No Python field exists today; the IUCr export already derives + them for `data_global` per §2.3a. +- **imgCIF alignment.** Not on the roadmap; explicitly deferred. diff --git a/docs/dev/adrs/accepted/minimizer-input-output-split.md b/docs/dev/adrs/accepted/minimizer-input-output-split.md index ee2656fc1..a36bf1355 100644 --- a/docs/dev/adrs/accepted/minimizer-input-output-split.md +++ b/docs/dev/adrs/accepted/minimizer-input-output-split.md @@ -156,12 +156,25 @@ live on `FitResultBase`; family-specific fields on the concrete classes: - `LeastSquaresFitResult` adds: `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, `correlation_available`, - `exit_reason`. + `exit_reason`, `r_factor_all`, `wr_factor_all`, `r_factor_gt`, + `wr_factor_gt`, `prof_r_factor`, `prof_wr_factor`, `prof_wr_expected`, + `number_restraints`, `number_constraints`, `shift_over_su_max`, + `shift_over_su_mean`, `profile_function`, `background_function`, + `threshold_expression`, `number_reflns_total`, `number_reflns_gt`. - `BayesianFitResult` adds: `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, `credible_interval_outer`, `resolved_random_seed`, `acceptance_rate_mean`, `gelman_rubin_max`, `effective_sample_size_min`, `best_log_posterior`. +The live deterministic result class may expose more descriptors than are +written for a specific saved result. `analysis/analysis.cif` serializes +the active subset: common LSQ descriptors, reflection descriptors only +when reflection rows exist, powder-profile descriptors only for +powder-profile results, and restraint / constraint counts only when +positive. Transient convergence diagnostics such as `shift_over_su_max` +and `shift_over_su_mean` remain live-result/report concerns and are not +part of the default fit-result CIF projection. + The three overlapping pairs from §"Context" are resolved by **dropping the `minimizer` copy** and keeping the `fit_result` copy: @@ -209,6 +222,19 @@ _fit_result.degrees_of_freedom 1016 _fit_result.covariance_available true _fit_result.correlation_available true _fit_result.exit_reason converged +_fit_result.R_factor_all 0.041 +_fit_result.wR_factor_all 0.052 +_fit_result.R_factor_gt 0.038 +_fit_result.wR_factor_gt 0.049 +_fit_result.prof_R_factor 0.041 +_fit_result.prof_wR_factor 0.052 +_fit_result.prof_wR_expected 0.031 +_fit_result.number_constraints 2 +_fit_result.profile_function pseudo_voigt +_fit_result.background_function chebyshev +_fit_result.threshold_expression I>3\s(I) +_fit_result.number_reflns_total 128 +_fit_result.number_reflns_gt 121 ``` Example Bayesian fit: diff --git a/docs/dev/adrs/accepted/project-facade-and-persistence.md b/docs/dev/adrs/accepted/project-facade-and-persistence.md index 3b922f423..17bcdc1a9 100644 --- a/docs/dev/adrs/accepted/project-facade-and-persistence.md +++ b/docs/dev/adrs/accepted/project-facade-and-persistence.md @@ -16,7 +16,7 @@ Persistence. `Project` is the top-level user facade. It owns project metadata, structures, experiments, rendering preferences, display helpers, -analysis, summaries, verbosity, and save/load behavior. +analysis, report helpers, verbosity, and save/load behavior. A later proposal considered renaming this facade to `Workspace` so that `project` could be reserved for the scientific project information @@ -35,16 +35,24 @@ directory of CIF files: ```text project_dir/ |-- project.cif -|-- summary.cif |-- structures/ |-- experiments/ -`-- analysis/ - `-- analysis.cif +|-- analysis/ +| `-- analysis.cif +`-- reports/ + `-- .cif ``` Real structures and experiments serialize as `data_` datablocks. -Singleton sections such as project configuration, analysis, and summary -serialize without fake `data_` headers. +Singleton sections such as project configuration and analysis serialize +without fake `data_` headers. Journal-submission reports are generated +through `project.report` and written only when requested, using +`reports/.cif`; default project saves do not write +`summary.cif`. + +Expose submission-report helpers as `project.report`. The previous +`project.summary` placeholder and its `summary.cif` output are not part +of the persistence layout. Keep project information available as `project.info`. The Python name avoids a confusing `project.project` access path, while the persisted diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index f8b3763b2..778b0db9c 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -38,6 +38,7 @@ folders. | Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | | Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | | Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | +| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds an IUCr submission report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | | Persistence | Suggestion | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then proposes scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](suggestions/python-cif-category-correspondence.md) | | Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | | Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | diff --git a/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md b/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md deleted file mode 100644 index 052412cbd..000000000 --- a/docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md +++ /dev/null @@ -1,184 +0,0 @@ -# ADR: IUCr CIF Tag Alignment for Fit Outputs - -**Status:** Proposed **Date:** 2026-05-24 - -## Status Note - -This suggestion captures research done during the -[`minimizer-input-output-split`](accepted/minimizer-input-output-split.md) -work. That ADR's `_fit_result.*` CIF prefix is functional but diverges -from the IUCr core and powder dictionaries. This proposal records the -divergence and a plausible alignment path so it is not lost; the work is -**not** in scope for the input/output-split PR. - -## Context - -After the input/output split, fit outputs persist under `_fit_result.*`. -The IUCr maintains two relevant dictionaries that already cover much of -the same ground: - -- [`COMCIFS/cif_core`](https://github.com/COMCIFS/cif_core) — core CIF - dictionary, refinement parameters under `_refine_ls.*` (51 items) and - aggregate reflection statistics under `_reflns.*`. -- [`COMCIFS/Powder_Dictionary`](https://github.com/COMCIFS/Powder_Dictionary) - (`cif_pow.dic`) — powder-specific refinement under `_pd_proc_ls.*` (9 - items). - -Cross-reference today: - -| Concept | Our `_fit_result.*` | IUCr core (`_refine_ls.*`) | IUCr powder (`_pd_proc_ls.*`) | -| ----------------------- | -------------------- | ------------------------------------------------ | ------------------------------------------------ | -| Reduced χ² / GoF | `reduced_chi_square` | `goodness_of_fit_all` (S, plus `_su`) | derived from `prof_wR_factor`/`prof_wR_expected` | -| R-factor unweighted | — | `r_factor_all`, `r_factor_gt` | `prof_R_factor` | -| R-factor weighted | — | `wr_factor_all`, `wr_factor_gt`, `wr_factor_ref` | `prof_wR_factor` | -| R-expected | — | (derived from S and counts) | `prof_wR_expected` | -| Number of data points | `n_data_points` | `number_reflns`, `number_reflns_gt` | derive from `_pd_proc.number_of_points` | -| Number of parameters | `n_parameters` | `number_parameters` | (in `_refine_ls.*`) | -| Number of restraints | — | `number_restraints` | same | -| Number of constraints | — | `number_constraints` | same | -| Shift / σ | — | `shift_over_su_max`, `shift_over_su_mean` | same | -| Profile function | — | — | `profile_function` | -| Background function | — | — | `background_function` | -| Wall time | `fitting_time` | (none) | (none) | -| Iteration count | `iterations` | (none) | (none) | -| Success flag, message | `success`, `message` | (none) | (none) | -| Bayesian R̂, ESS, accept | various | (none) | (none) | - -Two consequences: - -1. **Real gaps.** Our serialization omits R-factors, - restraint/constraint counts, shift/σ diagnostics, and powder - profile/background function names. These are fields that - crystallographers expect in a saved CIF. -2. **Naming divergence.** Where IUCr does have a tag, we use a different - prefix (`_fit_result.*` vs `_refine_ls.*` / `_pd_proc_ls.*`). Our - CIFs are valid but cannot be consumed by external tools that expect - IUCr-standard names. - -## Decision - -### 1. Use IUCr tag names where they exist - -For every `_fit_result.*` field that has a one-to-one IUCr counterpart, -emit the IUCr tag instead. The Python attribute name stays -(`analysis.fit_result.reduced_chi_square`); only the `cif_handler` -`names` tuple changes. Examples: - -- `fit_result.reduced_chi_square` → emits - `_refine_ls.goodness_of_fit_all` for single-crystal, - `_pd_proc_ls.prof_wR_factor` + `_pd_proc_ls.prof_wR_expected` for - powder (or both, with the GoF derived on the powder side). -- `fit_result.n_data_points` → `_refine_ls.number_reflns` - (single-crystal), `_pd_proc.number_of_points` (powder). -- `fit_result.n_parameters` → `_refine_ls.number_parameters`. - -The emitted prefix becomes shape-shifting based on -`experiment.type.scattering_type` and `experiment.type.sample_form` — -the same convention powder packages use today. The Python API remains -uniform. - -### 2. Add the missing IUCr fields to `fit_result` - -Promote the following from "gap" to "available" on the existing -`LeastSquaresFitResult`: - -- `r_factor_all`, `wr_factor_all` — emitted as the standard tags; - computed by the LSQ projection writer from residuals. -- `prof_r_factor`, `prof_wr_factor`, `prof_wr_expected` — - powder-specific variants, computed the same way against the profile - data. -- `number_restraints`, `number_constraints` — current count from the - analysis model. -- `shift_over_su_max`, `shift_over_su_mean` — last-iteration convergence - diagnostic. -- `profile_function`, `background_function` (powder) — string-form - descriptions of the active peak and background categories. - -### 3. Keep our own prefix for fields IUCr does not cover - -`fitting_time`, `iterations`, `success`, `message`, every Bayesian -diagnostic (`gelman_rubin_max`, `acceptance_rate_mean`, etc.), and the -`result_kind`/`point_estimate_name` markers have no IUCr home today. -They stay under a project-specific prefix. Two options for that prefix: - -- Keep `_fit_result.*` for the non-IUCr fields only. The CIF then mixes - prefixes (`_refine_ls.*` + `_fit_result.*`) which is unusual but - legal. -- Use a clearly-namespaced extension like `_easydiffraction.*` or - `_eddict_fit.*` so external tools recognise the fields as non-IUCr. - -Decide during implementation; the second option is friendlier to -external CIF readers. - -### 4. Bayesian Rietveld output has no IUCr precedent today - -There is no IUCr convention for sampler convergence (R̂, ESS, acceptance) -or posterior diagnostics. This proposal does not invent one. The -Bayesian-specific fields stay under the project prefix chosen in §3. If -a community standard emerges, a follow-on ADR can absorb it. - -## Consequences - -### Positive - -- Saved CIFs become consumable by standard IUCr tools (publCIF, - checkCIF, journal submission pipelines). -- The "what is the R-factor of this fit?" question has an answer in the - saved file — currently we only record reduced χ². -- Powder users get the conventional Rp / Rwp / Rexp triplet. -- We pick up restraint/constraint accounting that the structure side of - the project already tracks but does not currently emit. - -### Trade-offs - -- Shape-shifting CIF prefix per experiment family is more work to - implement than a single `_fit_result.*` prefix. Roughly: one - `cif_handler` per descriptor that maps to a different IUCr tag - depending on context; the Python API stays uniform. -- Bayesian fields remain non-standard. There is no way around that until - IUCr defines tags for Bayesian Rietveld. -- Existing saved projects from the post-split layout cannot load - unchanged. Beta posture applies; one more legacy-rename pass in - tutorial `ed-24` covers it. - -### ADRs amended by this ADR - -- [`analysis-cif-fit-state.md`](../accepted/analysis-cif-fit-state.md) — - replace the `_fit_result.*` projection description with the - IUCr-aligned tag set; document the per-experiment-family - shape-shifting. -- [`minimizer-input-output-split.md`](../accepted/minimizer-input-output-split.md) - — the `_fit_result.*` examples in §3 are updated to use IUCr tags for - the covered fields; the non-IUCr fields keep the project-prefix - examples. - -## Deferred Work - -- The exact prefix for non-IUCr fields (§3 option choice). -- IUCr-Bayesian alignment if a community standard appears. -- Single-crystal `r_factor_gt` / `wr_factor_gt` (greater-than-σ subsets) - need a "threshold expression" decision. The - `_reflns.threshold_expression` field already covers it on the - reflection side; the LSQ projection writer needs to know the threshold - to compute the `_gt` variants. -- Whether to also emit `_refine.special_details` for human-readable fit - notes. - -## Alternatives Considered - -### A. Keep `_fit_result.*` as-is - -Simplest. Saved CIFs stay self-contained but are not IUCr-portable. -Defensible if the project never targets external CIF interop. - -### B. Emit both prefixes for the covered fields - -`_fit_result.reduced_chi_square` and `_refine_ls.goodness_of_fit_all` -both present, holding the same value. Belt-and-braces, doubles the -surface area of every saved file, and the two values can drift. - -### C. Adopt IUCr tags only for tags we already need - -Add the R-factor fields (§2) under our own `_fit_result.*` prefix. -Smaller diff, fixes the missing-field gap but not the naming divergence. -Pick if IUCr interop is genuinely not a goal. diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index 9764d5fa1..4c714e752 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -55,6 +55,9 @@ │ │ │ ├── 📄 factory.py │ │ │ │ └── 🏷️ class FitResultFactory │ │ │ └── 📄 lsq.py +│ │ │ ├── 🏷️ class _LeastSquaresCoreProperties +│ │ │ ├── 🏷️ class _LeastSquaresReflectionProperties +│ │ │ ├── 🏷️ class _LeastSquaresPowderProperties │ │ │ └── 🏷️ class LeastSquaresFitResult │ │ ├── 📁 fitting_mode │ │ │ ├── 📄 __init__.py @@ -501,6 +504,19 @@ │ │ ├── 📄 __init__.py │ │ ├── 📄 handler.py │ │ │ └── 🏷️ class CifHandler +│ │ ├── 📄 iucr_transformers.py +│ │ │ ├── 🏷️ class IucrItem +│ │ │ ├── 🏷️ class IucrLoop +│ │ │ ├── 🏷️ class IucrCategoryTransformer +│ │ │ ├── 🏷️ class WavelengthTransformer +│ │ │ ├── 🏷️ class TofCalibrationTransformer +│ │ │ ├── 🏷️ class ExcludedRegionsTransformer +│ │ │ ├── 🏷️ class SymmetryOperationsTransformer +│ │ │ └── 🏷️ class ExtinctionTransformer +│ │ ├── 📄 iucr_writer.py +│ │ │ ├── 🏷️ class _FormulaValues +│ │ │ ├── 🏷️ class _PowderPhase +│ │ │ └── 🏷️ class _PowderPattern │ │ ├── 📄 parse.py │ │ └── 📄 serialize.py │ ├── 📄 __init__.py @@ -546,10 +562,13 @@ │ ├── 📄 project_config.py │ │ └── 🏷️ class ProjectConfig │ └── 📄 project_info.py -├── 📁 summary +├── 📁 report │ ├── 📄 __init__.py -│ └── 📄 summary.py -│ └── 🏷️ class Summary +│ ├── 📄 check.py +│ │ ├── 🏷️ class ReportCheckResult +│ │ └── 🏷️ class _GemmiLogger +│ └── 📄 report.py +│ └── 🏷️ class Report ├── 📁 utils │ ├── 📁 _vendored │ │ ├── 📁 jupyter_dark_detect diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index 25e3c1acd..0b589d63d 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -239,6 +239,8 @@ │ ├── 📁 cif │ │ ├── 📄 __init__.py │ │ ├── 📄 handler.py +│ │ ├── 📄 iucr_transformers.py +│ │ ├── 📄 iucr_writer.py │ │ ├── 📄 parse.py │ │ └── 📄 serialize.py │ ├── 📄 __init__.py @@ -269,9 +271,10 @@ │ ├── 📄 project.py │ ├── 📄 project_config.py │ └── 📄 project_info.py -├── 📁 summary +├── 📁 report │ ├── 📄 __init__.py -│ └── 📄 summary.py +│ ├── 📄 check.py +│ └── 📄 report.py ├── 📁 utils │ ├── 📁 _vendored │ │ ├── 📁 jupyter_dark_detect diff --git a/docs/dev/plans/iucr-cif-tag-alignment.md b/docs/dev/plans/iucr-cif-tag-alignment.md new file mode 100644 index 000000000..4658b5728 --- /dev/null +++ b/docs/dev/plans/iucr-cif-tag-alignment.md @@ -0,0 +1,632 @@ +# Plan: IUCr CIF Tag Alignment + +Implementation plan for the +[`iucr-cif-tag-alignment`](../adrs/accepted/iucr-cif-tag-alignment.md) +ADR. Follows [`AGENTS.md`](../../../AGENTS.md) — no deliberate +exceptions to those instructions. + +## ADR cross-reference + +- Primary ADR: `iucr-cif-tag-alignment.md` (accepted; this plan promoted + it from `suggestions/` during Phase 1). +- Amends (per the ADR's "ADRs amended by this ADR" section): + - [`analysis-cif-fit-state.md`](../adrs/accepted/analysis-cif-fit-state.md) + — new `_fit_result.*` fields; topology-neutral default save. + - [`minimizer-input-output-split.md`](../adrs/accepted/minimizer-input-output-split.md) + — new `_fit_result.*` examples. + - [`project-facade-and-persistence.md`](../adrs/accepted/project-facade-and-persistence.md) + — `project.summary` removed and replaced by `project.report`; + `summary.cif` no longer written. + - [`help-discoverability.md`](../adrs/accepted/help-discoverability.md) + — `project.summary.help()` → `project.report.help()`. + +## Branch and PR + +- Branch: `iucr-cif-tag-alignment` (already checked out, matches the + slug). +- PR target: `develop`. +- Do not push the branch until both Phase 1 and Phase 2 review cycles + close. + +## Decisions already made (in the ADR) + +These are settled by the accepted ADR — the plan does not re-litigate +them, only implements them: + +- **Three-tier default save:** structure-tier IUCr alignment with casing + fixes; analysis-tier topology-neutral `_fit_result.*` with + dictionary-canonical _item_ names (uppercase R / wR / DOI); + experiment-tier unchanged. Per-topology category split (`_refine_ls.*` + / `_pd_proc_ls.*` / `_reflns.*`) happens only in the IUCr export. +- **Reports system:** new `project.report` facade slot replaces the + unimplemented `project.summary` placeholder; single + `reports/.cif` file with multi-datablock layout always + starting with `data_global`. `project.save(report=True)` and + `project.report.save()` produce the file; `project.report.check()` + validates via gemmi against `cif_core.dic` / `cif_pow.dic`. +- **Handler mechanism:** per-field `iucr_name` (optional, falls back to + `names[0]`) plus category-level `IucrCategoryTransformer` subclasses + for wavelength, TOF calibration, excluded regions, symmetry + operations, and extinction reshaping. +- **ADP single-tag emission:** emit `B_*` xor `U_*` per row based on + `_atom_site.ADP_type`; both forms still accepted on read. +- **Loop-tag style:** dotted DDLm everywhere on write; both forms on + read. +- **`_easydiffraction_software`** umbrella category with three free-text + fields (`framework`, `calculator`, `minimizer`), plus a derived + `_computing.structure_refinement` string in the IUCr export. +- **gemmi** is already a project dependency (`pyproject.toml:39`); no + new dependencies needed. + +## Open questions to resolve during implementation + +- **Pixi env:** confirm `gemmi.cif.read_doc` and the dictionary + validation path are available in the project's pinned gemmi version + before P1.16 (validator implementation). If the pinned version is too + old, bump the constraint in `pyproject.toml` (counts as a plan-named + dependency change — pre-approved per AGENTS.md §Architecture because + the package is already named). +- **Tutorial scope:** identify every tutorial source under + `docs/docs/tutorials/*.py` that references `project.summary` and + update them in P1.17. If a tutorial currently uses + `project.summary.help()` as a discoverability example, it becomes + `project.report.help()`; if a tutorial expects `summary.cif`, swap to + `project.save(report=True)` and reference `reports/.cif`. +- **Extinction transformer detail:** the `(type, model)` → + `_refine_ls.extinction_method` descriptive string in §3 of the ADR + uses a Becker-Coppens taxonomy table; the implementation needs the + project's existing extinction-model selector enums to map cleanly. + Verify enum values during P1.14. + +## Concrete files likely to change + +Foundation: + +- `src/easydiffraction/io/cif/handler.py` — `CifHandler` gains + `iucr_name` parameter. + +Structure tier (casing): + +- `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py` +- `src/easydiffraction/datablocks/structure/categories/space_group/default.py` + +ADP write-side: + +- `src/easydiffraction/io/cif/serialize.py` (atom_site / atom_site_aniso + write path) + +Analysis tier (new `_fit_result.*` fields): + +- `src/easydiffraction/analysis/categories/fit_result/lsq.py` +- `src/easydiffraction/analysis/categories/fit_result/base.py` +- `src/easydiffraction/analysis/fit/` (residual / aggregate computation + site; exact module determined during P1.4) + +Project-extension `iucr_name` settings (P1.7): + +- The descriptor files enumerated in P1.7 (one `cif_handler` call per + descriptor across the analysis / experiment categories — no new + modules, no new packages). The software triple itself is built inline + by the IUCr writer (P1.11) and does **not** introduce a new category + class or default-save persistence. + +Facade rename `project.summary` → `project.report` (P1.8): + +- `src/easydiffraction/project/project.py` (replace the `summary` + property and `summary.cif` write with the new `report` facade; drop + the `as_cif()` caller; add the `report: bool = False` keyword to + `Project.save()`). +- `src/easydiffraction/summary/` → migrated to + `src/easydiffraction/report/` (either rename the package and class, or + add a fresh `report/` package whose `Report` delegates to the migrated + display methods — implementer's choice). Every live display method + (`show_report`, `show_project_info`, `show_crystallographic_data`, + `show_experimental_data`, `show_fitting_details`) is preserved + verbatim; only the placeholder `as_cif()` is dropped. +- `src/easydiffraction/report/__init__.py`, `report.py` — destination of + the migration. + +IUCr writer: + +- `src/easydiffraction/io/cif/iucr_writer.py` (new) +- `src/easydiffraction/io/cif/iucr_transformers.py` (new — holds + `IucrCategoryTransformer` subclasses) + +Validation: + +- `src/easydiffraction/report/check.py` (new — wraps `gemmi`) + +Amended ADRs: + +- `docs/dev/adrs/accepted/analysis-cif-fit-state.md` +- `docs/dev/adrs/accepted/minimizer-input-output-split.md` +- `docs/dev/adrs/accepted/project-facade-and-persistence.md` +- `docs/dev/adrs/accepted/help-discoverability.md` + +ADR promotion: + +- `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` → moved to + `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md` with status flipped + to Accepted. +- `docs/dev/adrs/index.md` (index row updated). + +Tutorials / CLI: + +- `docs/docs/tutorials/*.py` (regenerate notebooks after edits via + `pixi run notebook-prepare`). +- `src/easydiffraction/cli/` (any command that surfaces + `project.summary`). + +## Commit discipline + +When an AI agent follows this plan, **every completed Phase 1 +implementation step must be staged with explicit paths and committed +locally before moving to the next implementation step or the Phase 1 +review gate.** Follow the rules in [`AGENTS.md`](../../../AGENTS.md) → +**Commits**. Keep commits atomic, single-purpose, and aligned with the +plan steps. Do not include generated artifacts (data CIFs, project +directories, benchmark CSVs) unless the step explicitly produces them — +see **Workflow** in [`AGENTS.md`](../../../AGENTS.md) for the +generated-artifact exceptions. + +## Implementation steps (Phase 1) + +- [x] **P1.1 — Extend `CifHandler` with `iucr_name`** + - File: `src/easydiffraction/io/cif/handler.py`. + - Add an optional keyword `iucr_name: str | None = None` to + `CifHandler.__init__`. + - Add a public property / method returning the IUCr-side tag: + `iucr_name` when set, else `names[0]`. + - No call sites change in this step — the default fallback leaves + every existing handler emitting its current name. + - Commit: `Add iucr_name to CifHandler`. + +- [x] **P1.2 — Structure-tier casing fixes** + - Files: + `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py`, + `src/easydiffraction/datablocks/structure/categories/space_group/default.py`. + - Rename canonical CIF tags to dictionary-canonical casing + (`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`, + `_space_group.name_H-M_alt`, + `_space_group.IT_coordinate_system_code`). Python attribute names + stay lowercase. + - Keep the old (lowercase / `wyckoff_letter`) forms in each + `CifHandler.names` list as read-only aliases so loading legacy files + still works. + - Commit: `Adopt IUCr casing for atom_site and space_group CIF tags`. + +- [x] **P1.3 — ADP single-tag emission per row** + - File: `src/easydiffraction/io/cif/serialize.py` (and any helper + called by it). + - When emitting `_atom_site_aniso.*` and `_atom_site.B_iso_or_equiv` / + `_atom_site.U_iso_or_equiv`, choose `B_*` or `U_*` per row based on + `_atom_site.ADP_type`; omit the other family for that row. + - Read side unchanged (both families still accepted). + - Commit: `Emit one ADP family per atom_site row on save`. + +- [x] **P1.4 — Analysis tier: new `_fit_result.*` fields** + - Files: `src/easydiffraction/analysis/categories/fit_result/lsq.py`, + `src/easydiffraction/analysis/categories/fit_result/base.py`, plus + the fit-computation site (search for where `n_data_points`, + `reduced_chi_square` are currently populated). + - Declare new descriptors under `_fit_result.*` with + dictionary-canonical _item_ names (uppercase R / wR): + `R_factor_all`, `wR_factor_all`, `R_factor_gt`, `wR_factor_gt`, + `prof_R_factor`, `prof_wR_factor`, `prof_wR_expected`, + `number_restraints`, `number_constraints`, `shift_over_su_max`, + `shift_over_su_mean`, `profile_function`, `background_function`, + `threshold_expression`, `number_reflns_total`, `number_reflns_gt`. + - Wire computation: R-factors from residuals; restraint / constraint + counts from the analysis model; profile / background descriptors + from the active peak / background categories; reflns aggregates from + refln data. + - Fields not meaningful for a given fit (e.g. `prof_R_factor` for SC) + stay unset / `None`. + - Commit: + `Add IUCr-canonical fit_result fields to LeastSquaresFitResult`. + +- [x] **P1.5 — Amend `analysis-cif-fit-state.md`** + - File: `docs/dev/adrs/accepted/analysis-cif-fit-state.md`. + - Document the new `_fit_result.*` fields, the topology-neutral + default-save policy, and the per-topology IUCr-export remapping + deferred to §3 of the iucr-cif-tag-alignment ADR. + - Commit: + `Amend analysis-cif-fit-state ADR for new fit_result fields`. + +- [x] **P1.6 — Amend `minimizer-input-output-split.md`** + - File: `docs/dev/adrs/accepted/minimizer-input-output-split.md`. + - Update the `_fit_result.*` examples in §3 to reflect the new field + set from P1.4. + - Commit: `Amend minimizer-input-output-split ADR examples`. + +- [x] **P1.7 — Set `iucr_name` on project-extension descriptors** + - No new category. The ADR's `_easydiffraction_software` triple is a + **report-only projection**: it is derived inline by the IUCr writer + in P1.11 from existing state (`easydiffraction` package version, + `project.analysis.calculator.type`, + `project.analysis.minimizer.type`, and per-backend version + metadata). It is **not** persisted to `analysis/analysis.cif` and no + new category class is added. + - This step's actual work is the `_easydiffraction_*` prefix rename + for existing project-extension descriptors at IUCr export time. For + each project-extension category, set the matching descriptor's + `cif_handler` `iucr_name` to the prefixed form so the IUCr writer + emits `_easydiffraction_.` while the default save + keeps the bare-category form (`_minimizer.*`, `_calculator.*`, + etc.). + - Touched descriptors: + - `_minimizer.*` → `iucr_name='_easydiffraction_minimizer.*'` + (settings only — type, tolerance, max_iter, …). + - `_calculator.*` → `iucr_name='_easydiffraction_calculator.*'`. + - `_fitting_mode.*` → `iucr_name='_easydiffraction_fitting_mode.*'`. + - `_alias.*` → `iucr_name='_easydiffraction_alias.*'`. + - `_constraint.*` → `iucr_name='_easydiffraction_constraint.*'`. + - `_joint_fit.*` → `iucr_name='_easydiffraction_joint_fit.*'`. + - `_sequential_fit.*`, `_sequential_fit_extract.*` → + `iucr_name='_easydiffraction_sequential_fit*.*'`. + - `_expt_type.*` → `iucr_name='_easydiffraction_experiment_type.*'`. + - `_excluded_region.*` → + `iucr_name='_easydiffraction_excluded_region.*'`. + - `_peak.*` → `iucr_name='_easydiffraction_peak.*'`. + - `_extinction.*` (project-side selectors and parameters) → + `iucr_name='_easydiffraction_extinction.*'` (the transformer in + P1.14 also dual-emits the coreCIF `_refine_ls.extinction_*` + triple). + - `_background.type` → + `iucr_name='_easydiffraction_background.type'`. + - `_sc_crystal_block.*` → + `iucr_name='_easydiffraction_sc_crystal_block.*'`. + - Bayesian-only `_fit_result.*` fields → + `iucr_name='_easydiffraction_fit_result.*'` (project extensions + per ADR §3.3). + - The non-IUCr-counterpart `_diffrn.ambient_magnetic_field` and + `_diffrn.ambient_electric_field` descriptors get + `iucr_name='_easydiffraction_diffrn.ambient_magnetic_field'` / + `…electric_field`. + - Default-save behaviour is unchanged for every touched descriptor — + only the IUCr-export emission picks up the new prefix. + - Commit: `Set iucr_name on project-extension descriptors`. + +- [x] **P1.8 — Rename `project.summary` → `project.report`, preserve + display methods** + - Files: `src/easydiffraction/project/project.py`, + `src/easydiffraction/summary/` (renamed / migrated), + `src/easydiffraction/report/` (new). + - **Preserve every live `Summary` method.** The existing `Summary` + class (at `src/easydiffraction/summary/summary.py`) has live + user-facing display methods that tutorials call: `show_report()`, + `show_project_info()`, `show_crystallographic_data()`, + `show_experimental_data()`, `show_fitting_details()`. These are not + placeholders and must not be removed. Move them verbatim onto the + new `Report` class (rename of the class only, with no behavioural + change), so `project.report.show_report()` and friends remain + available with the same signatures and output. + - **Drop only the placeholder CIF method.** The `as_cif()` method on + `Summary` returns a stub string and is the only placeholder being + removed. Its caller — the `summary.cif` write at `Project.save()` + (currently at `src/easydiffraction/project/project.py:464`-ish) — is + removed in the same commit. The real CIF emission for journal + submission lives in `Report.save()` (writes `reports/.cif`, + real implementation lands in P1.15) — the two are independent: + dropping `as_cif()` does not break any user-visible behaviour + because nothing meaningful was being written. + - Migration mechanics: + - Either rename the package directory + (`src/easydiffraction/summary/` → `src/easydiffraction/report/`) + and class (`Summary` → `Report`) in one move, **or** add a new + `src/easydiffraction/report/report.py` whose `Report` class + inherits / delegates to the migrated display methods. Pick the + approach that produces the smallest diff at review time; both keep + the display behaviour intact. + - Update `__init__.py` re-exports accordingly. + - `Project` gains a `report` property returning the `Report` instance; + the old `summary` property is removed. The `summary.cif` write call + in `Project.save()` is removed. + - `Project.save()` gains a `report: bool = False` keyword (no + behaviour yet beyond passing through to `Report.save()` when truthy; + the real `Report.save()` lands in P1.15). + - Tutorial / CLI call sites that invoke + `project.summary.show_report()` are updated in **P1.17**. + - Commit: `Replace project.summary with project.report facade`. + +- [x] **P1.9 — Amend `project-facade-and-persistence.md`** + - File: `docs/dev/adrs/accepted/project-facade-and-persistence.md`. + - Document the `project.report` facade slot, removal of + `project.summary`, removal of `summary.cif` from default saves, and + the new `reports/.cif` output path. + - Commit: + `Amend project-facade-and-persistence ADR for project.report`. + +- [x] **P1.10 — Amend `help-discoverability.md`** + - File: `docs/dev/adrs/accepted/help-discoverability.md`. + - Replace `project.summary.help()` with `project.report.help()` in the + help-surface enumeration. + - Commit: `Amend help-discoverability ADR for project.report`. + +- [x] **P1.11 — IUCr writer foundation + `data_global` content** + - New file: `src/easydiffraction/io/cif/iucr_writer.py`. + - Implement the multi-datablock orchestrator skeleton: a + `write_iucr_cif(project, path)` entry point that opens + `reports/.cif`, emits `data_global` first, then delegates + topology-specific blocks to subordinate writers (added in P1.12 / + P1.13). + - Emit `data_global` content per §2.3a of the ADR: + `_audit.creation_method` / `_audit.creation_date`, + `_computing.structure_refinement` (derived from the + `_easydiffraction_software` triple), + `_easydiffraction_software.{framework, calculator, minimizer}`, + `_journal.*` and `_publ_*` placeholders (written as `?`), + `_chemical_formula.*` derived from atom sites where possible. + - Apply the §2.4 formatting rules: blank line between categories, + `# ----
----` headers, 80-char wrap on long strings, + dotted DDLm form throughout, project extensions grouped at the end + of each block. + - Wire `Report.save()` (stubbed in P1.8) to call `write_iucr_cif`. + - Commit: `Add IUCr CIF writer with data_global block`. + +- [x] **P1.12 — Single-crystal block layout** + - Same file as P1.11; add `_write_sc_block` helper. + - Per-structure block emission per §2.3b: `_chemical_formula.*`, + `_cell.*`, `_space_group.*` + `_space_group_symop.*` loop, + `_diffrn.*`, `_diffrn_radiation_wavelength.*` (scalar / single-row + category for monochromatic per the wavelength transformer), + `_atom_site.*` + `_atom_site_aniso.*` loops with ADP single-tag + emission, `_refine_ls.*`, `_reflns.*`, the `_refln.*` loop per §2.3c + (`index_h/k/l`, `F_squared_meas`, `F_squared_calc`, + `F_squared_meas_su`, `include_status`). + - For SC the project-extension `_easydiffraction_extinction.*` block + + the dual `_refine_ls.extinction_*` triple are emitted via the + transformer (added in P1.14). + - Commit: `Emit single-crystal blocks in IUCr CIF writer`. + +- [x] **P1.13 — Powder Rietveld block layout (CWL + TOF)** + - Same writer file; add `_write_rietveld_blocks` helper. + - Emit `data__overall`, `data__phase_N` (one per + phase), `data__pwd_N` (one per pattern) per §2.3f / §2.3g / + §2.3h. + - Profile-data loop columns: CWL form uses `_pd_meas.2theta_scan`; TOF + form uses `_pd_meas.time_of_flight`. Other columns: + `_pd_meas.intensity_total`, `_pd_calc.intensity_total`, + `_pd_proc.intensity_bkg_calc`, `_pd_proc_ls.weight`. + - Powder reflections loop per §2.3d: + `_refln.{index_h/k/l, F_squared_meas, F_squared_calc, phase_calc, d_spacing}`. + - Cross-block reference markers (`_pd_block_id`, + `_pd_block_diffractogram_id`) emitted with pipe-delimited + identifiers matching the §2.3 examples. + - Joint Rietveld and sequential fits emit one `_pwd_N` per pattern / + step inside the same file. + - Commit: `Emit powder Rietveld blocks in IUCr CIF writer`. + +- [x] **P1.14 — `IucrCategoryTransformer` subclasses** + - New file: `src/easydiffraction/io/cif/iucr_transformers.py`. + - Implement and register five transformers per §3 of the ADR: + - **Wavelength** — monochromatic scalar / single-row category; + multi-row loop when applicable. + - **TOF calibration** — four-row + `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}` + loop with EasyDiffraction attribute names as `id` codes (`offset`, + `linear`, `quad`, `recip`) and powers 0, 1, 2, −1 respectively per + the §2.3h cif_pow.dic equation. + - **Excluded regions** — free-text rendering as + `_pd_proc.info_excluded_regions`. + - **Symmetry operations** — `_space_group_symop.*` loop derived from + the active space group. + - **Extinction** — dual emit `_easydiffraction_extinction.*` + the + coreCIF `_refine_ls.extinction_{method,coef,expression}` triple, + with the descriptive string built from the project's + `(type, model)` selectors per the ADR §3 mapping table. + - Wire transformers into the writer from P1.12 / P1.13 (the writer + asks each per-block category for its IUCr representation, which is + either a direct `iucr_name` rename or a transformer call). + - Commit: `Add IUCr category transformers for restructured emissions`. + +- [x] **P1.15 — Wire `Project.save(report=True)` end-to-end** + - File: `src/easydiffraction/project/project.py`, + `src/easydiffraction/report/report.py`. + - `Project.save(report=False)` continues to write the regular project + files. `Project.save(report=True)` additionally writes + `reports/.cif`. + - `Project.save(report=True, check=False)` is the default for the + report path; `check=True` is wired in P1.16. + - Make the `reports/` directory if absent; overwrite an existing + report file (no round-trip). + - Commit: `Wire report=True kwarg on Project.save`. + +- [x] **P1.16 — Submission-side validation via gemmi** + - New file: `src/easydiffraction/report/check.py`. + - Implement `Report.check()` using `gemmi.cif.read_doc` against the + shipped (or downloaded) `cif_core.dic` and `cif_pow.dic`. Skip + `_easydiffraction_*` from unknown-tag warnings. + - Wire `Project.save(report=True, check=True)` to run validation after + the write and surface any errors / warnings to the user. + - Verify gemmi version supports the validation calls in the project's + pinned env; if not, bump the constraint in `pyproject.toml` / + `pixi.toml` / `pixi.lock` (pre-approved per AGENTS.md §Architecture + because gemmi is already a named dependency). + - Commit: `Add Report.check() validation via gemmi`. + +- [x] **P1.17 — Update tutorials / CLI / docs references** + - Source files in `docs/docs/tutorials/*.py` and CLI commands in + `src/easydiffraction/cli/`. + - Replace every `project.summary.*` call site with the matching + `project.report.*` call: + - `project.summary.show_report()` → `project.report.show_report()` + (currently used by `docs/docs/tutorials/ed-3.py`, + `docs/docs/tutorials/ed-5.py`, `docs/docs/tutorials/ed-6.py`, + `docs/docs/tutorials/ed-8.py` — confirm the full list via + `git grep -n 'project\.summary'` at the start of the step and + update every match). + - Other `project.summary.*` accessors (`show_project_info`, + `show_crystallographic_data`, `show_experimental_data`, + `show_fitting_details`) — replace each call with the + `project.report.*` equivalent. + - `project.summary.help()` in any tutorial or doc page becomes + `project.report.help()`. + - Demonstrate `project.save(report=True)` in at least one tutorial + that finishes a fit; reference `reports/.cif` in the prose. + - Regenerate notebooks with `pixi run notebook-prepare` (per AGENTS.md + §Tutorials). + - Commit: `Update tutorials and CLI for project.report rename`. + +- [x] **P1.18 — Promote ADR to `accepted/`** + - Move `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` to + `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md`. + - Flip the front-matter `**Status:**` line from `Proposed` to + `Accepted`. Update the date to today (Phase 1 acceptance date). + - Update `docs/dev/adrs/index.md`: the row's status column changes + from `Suggestion` to `Accepted`, and the link target changes from + `suggestions/` to `accepted/`. + - Commit: `Promote iucr-cif-tag-alignment ADR to accepted`. + +- [x] **P1.19 — Reach Phase 1 review gate** + - No-code step. Mark every `[ ]` above as `[x]`; commit the plan-file + update alone. + - Commit: `Reach Phase 1 review gate`. + +## Test plan (Phase 2) + +Per AGENTS.md §Testing, every new module, class, and bug fix ships with +tests; unit tests mirror the source tree. Before running the +verification commands below, add or update: + +- [x] **`tests/unit/easydiffraction/io/cif/test_handler.py`** — + `CifHandler.iucr_name` resolver: explicit value used when set, + fallback to `names[0]` when unset. P1.1 surface. +- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_writer.py`** — + fixture-driven golden tests for each topology covered in §2.3 / + §2.2 worked examples: single-crystal (Example A), single- + experiment Rietveld CWL (Example B), joint Rietveld multi- + experiment (Example C), sequential TOF Rietveld (Example D). Each + golden compares the emitted file against a checked-in reference + and asserts: block names, block order, `data_global` content, + profile-data / reflections loop columns, project-extension + `_easydiffraction_*` grouping at end of block, 80-char wrap. + P1.11–P1.13 surface. +- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_transformers.py`** + — per-transformer unit tests: wavelength scalar vs loop based on + multiplicity; TOF calibration loop with + `id = offset / linear / quad / recip` and powers 0, 1, 2, −1; + range-form excluded regions rendered to + `_pd_proc.info_excluded_regions`; `_space_group_symop.*` loop + derived from the active space group; extinction `(type, model)` → + `_refine_ls.extinction_method` descriptive string and + `_refine_ls.extinction_coef` value per the ADR §3 mapping table + (Becker-Coppens type 1 / type 2 / mixed, Zachariasen). P1.14 + surface. +- [x] **`tests/unit/easydiffraction/io/cif/test_serialize.py`** — update + to cover ADP single-tag emission: rows with `ADP_type='Biso'` / + `'Bani'` emit only the `B_*` family; rows with `ADP_type='Uiso'` / + `'Uani'` emit only the `U_*` family. P1.3 surface. +- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/atom_sites/test_default.py`** + — update for the casing fixes from P1.2: `_atom_site.ADP_type` + (uppercase ADP), `_atom_site.Wyckoff_symbol` (uppercase W, + "symbol"); legacy lowercase forms still loadable on read. +- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/space_group/test_default.py`** + — update for `_space_group.name_H-M_alt` and + `_space_group.IT_coordinate_system_code` casing fixes (P1.2). +- [x] **`tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py`** + — update for the new `_fit_result.*` fields from P1.4: each new + descriptor (`R_factor_all`, `wR_factor_all`, `R_factor_gt`, + `wR_factor_gt`, `prof_R_factor`, `prof_wR_factor`, + `prof_wR_expected`, `number_restraints`, `number_constraints`, + `shift_over_su_max`, `shift_over_su_mean`, `profile_function`, + `background_function`, `threshold_expression`, + `number_reflns_total`, `number_reflns_gt`) is read / written + round-trip; fields unset for inapplicable experiment families + remain `None`. +- [x] **`tests/unit/easydiffraction/report/test_report.py`** — new + `Report` class: `save()` writes `reports/.cif`; preserved + display methods (`show_report`, `show_project_info`, + `show_crystallographic_data`, `show_experimental_data`, + `show_fitting_details`) keep their existing behaviour (port the + relevant assertions from the current + `tests/unit/easydiffraction/summary/`-equivalent tests if any + exist; otherwise add coverage). P1.8 surface. +- [x] **`tests/unit/easydiffraction/report/test_check.py`** — + `Report.check()` validates the generated CIF against + `cif_core.dic` / `cif_pow.dic` via gemmi; surfaces unknown- tag + warnings for non-extension categories; ignores the + `_easydiffraction_*` namespace per the configured skip list. P1.16 + surface. +- [x] **`tests/unit/easydiffraction/project/test_project.py`** — update + / extend: `Project.save()` no longer writes `summary.cif`; + `Project.save(report=True)` writes `reports/.cif`; + `Project.save(report=True, check=True)` runs validation; the + `project.report` facade exposes `save`, `check`, and the preserved + display methods. P1.8, P1.15, P1.16 surface. +- [x] **`tests/unit/easydiffraction/analysis/test_analysis.py`** — + update for the project-extension `iucr_name` settings on + `_minimizer.*`, `_calculator.*`, `_fitting_mode.*` (P1.7): + default-save CIF tags unchanged; IUCr-export `iucr_name` resolves + to `_easydiffraction_*` prefix. +- [x] **Script / tutorial coverage.** Verify `pixi run script-tests` + exercises at least one tutorial that calls + `project.save(report=True)` and the resulting + `reports/.cif` is non-empty and gemmi-valid. If no + tutorial exercises this, extend the relevant tutorial source per + P1.17 and regenerate the notebook. + +Use `pixi run test-structure-check` to confirm the unit-test layout +mirrors the source tree per AGENTS.md §Testing. + +## Verification commands (Phase 2) + +Per AGENTS.md §Workflow, save any required check output with the +zsh-safe pattern. Variable names per-task: + +```sh +pixi run fix > /tmp/easydiffraction-fix.log 2>&1; fix_exit_code=$?; tail -n 200 /tmp/easydiffraction-fix.log; exit $fix_exit_code +pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code +pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; unit_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-unit-tests.log; exit $unit_tests_exit_code +pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; integration_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-integration-tests.log; exit $integration_tests_exit_code +pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; script_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-script-tests.log; exit $script_tests_exit_code +``` + +Run in order; each must complete clean before the next. The +`pixi run script-tests` pass may surface tutorial path collisions or +stale tutorials — apply the tutorial-source fix from AGENTS.md §Workflow +("If `pixi run script-tests` fails because two tutorials write to the +same project directory…") rather than deleting project output. Benchmark +CSVs under `docs/dev/benchmarking/` produced by `pixi run script-tests` +are untracked verification artifacts; do not stage them. + +## Suggested Pull Request + +**Title:** +`[scope] Align CIF tags with IUCr dictionaries and add journal-submission export` + +**Description:** + +EasyDiffraction now writes day-to-day project CIFs in a form that +matches the IUCr core and powder dictionaries where it makes sense, +while keeping the experiment-side names friendly for users who edit CIFs +by hand. Structure CIFs adopt the dictionary casing +(`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`, +`_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`), +and atomic displacement parameters are written using a single family per +row (`B_*` or `U_*`) based on `_atom_site.ADP_type`. Analysis CIFs gain +a richer set of fit-output statistics — the standard Rietveld R-factor +family, restraint and constraint counts, shift / σ diagnostics, profile +and background function descriptors — all under the existing +topology-neutral `_fit_result.*` category, with item names matching IUCr +casing so they are immediately recognisable to anyone familiar with +`_refine_ls.*` / `_pd_proc_ls.*` from publications. + +A new `project.report` facade replaces the previously empty +`project.summary` placeholder. Calling `project.save(report=True)` (or +`project.report.save()`) generates a single journal-submission CIF at +`reports/.cif` — one multi- datablock file ready to upload to +IUCr journals, with the publication-metadata `data_global` block holding +`?` placeholders the user fills in before submission. The new +`project.report.check()` runs the generated file through `gemmi` against +the IUCr dictionaries to surface any tag, category, or type issues +before the upload. + +The PR also amends four accepted ADRs (`analysis-cif-fit-state`, +`minimizer-input-output-split`, `project-facade-and-persistence`, +`help-discoverability`) to reflect the new facade and field set, and +promotes the `iucr-cif-tag-alignment` ADR to `accepted/`. + +**Scope label:** `[analysis]` or `[io]` — pick whichever the maintainers +prefer for the IUCr-export work; the field renames in `analysis.cif` +lean `[analysis]`, the new writer leans `[io]`. diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 351d7f563..6f1f30354 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -23,4 +23,4 @@ available in EasyDiffraction: - [project](project.md) – Defines the project and manages its state. - [analysis](analysis.md) – Provides tools for analyzing diffraction data, including fitting and minimization. -- [summary](summary.md) – Provides a summary of the project. +- [report](report.md) – Provides project report and submission helpers. diff --git a/docs/docs/api-reference/report.md b/docs/docs/api-reference/report.md new file mode 100644 index 000000000..7a3ae20f9 --- /dev/null +++ b/docs/docs/api-reference/report.md @@ -0,0 +1 @@ +::: easydiffraction.report diff --git a/docs/docs/api-reference/summary.md b/docs/docs/api-reference/summary.md deleted file mode 100644 index 8cf1a0564..000000000 --- a/docs/docs/api-reference/summary.md +++ /dev/null @@ -1 +0,0 @@ -::: easydiffraction.summary diff --git a/docs/docs/tutorials/ed-13.ipynb b/docs/docs/tutorials/ed-13.ipynb index a99270afa..6b6d8a2dd 100644 --- a/docs/docs/tutorials/ed-13.ipynb +++ b/docs/docs/tutorials/ed-13.ipynb @@ -2585,7 +2585,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_2.summary.show_report()" + "project_2.report.show_report()" ] }, { @@ -2657,7 +2657,7 @@ ], "metadata": { "jupytext": { - "cell_metadata_filter": "title,tags,-all", + "cell_metadata_filter": "tags,title,-all", "main_language": "python", "notebook_metadata_filter": "-all" } diff --git a/docs/docs/tutorials/ed-13.py b/docs/docs/tutorials/ed-13.py index 18400d55b..b19ff734a 100644 --- a/docs/docs/tutorials/ed-13.py +++ b/docs/docs/tutorials/ed-13.py @@ -1461,7 +1461,7 @@ # when relevant. # %% -project_2.summary.show_report() +project_2.report.show_report() # %% [markdown] # Finally, we save the project to disk to preserve the current state of diff --git a/docs/docs/tutorials/ed-14.ipynb b/docs/docs/tutorials/ed-14.ipynb index 48665fa53..f5243fb0f 100644 --- a/docs/docs/tutorials/ed-14.ipynb +++ b/docs/docs/tutorials/ed-14.ipynb @@ -64,7 +64,8 @@ "outputs": [], "source": [ "# Create minimal project without name and description\n", - "project = ed.Project()" + "project = ed.Project()\n", + "project.save_as('projects/tbti_heidi')" ] }, { diff --git a/docs/docs/tutorials/ed-14.py b/docs/docs/tutorials/ed-14.py index 3ff5f25f0..ab5969c85 100644 --- a/docs/docs/tutorials/ed-14.py +++ b/docs/docs/tutorials/ed-14.py @@ -16,6 +16,7 @@ # %% # Create minimal project without name and description project = ed.Project() +project.save_as('projects/tbti_heidi') # %% [markdown] # ## Step 2: Define Structure diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb index dce64e629..acb57a326 100644 --- a/docs/docs/tutorials/ed-3.ipynb +++ b/docs/docs/tutorials/ed-3.ipynb @@ -1546,7 +1546,7 @@ "id": "151", "metadata": {}, "source": [ - "#### Save Project State" + "#### Save Project State and Submission Report" ] }, { @@ -1556,7 +1556,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save()" + "project.save(report=True)" ] }, { @@ -1564,9 +1564,12 @@ "id": "153", "metadata": {}, "source": [ - "## Step 5: Summary\n", + "## Step 5: Report\n", + "\n", + "This final section shows how to review the results of the analysis.\n", "\n", - "This final section shows how to review the results of the analysis." + "The saved IUCr submission CIF is available under `reports/.cif`\n", + "inside the project directory." ] }, { @@ -1574,7 +1577,7 @@ "id": "154", "metadata": {}, "source": [ - "#### Show Project Summary" + "#### Show Project Report" ] }, { @@ -1584,7 +1587,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.summary.show_report()" + "project.report.show_report()" ] } ], diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py index bc6fcda49..fe25bcc47 100644 --- a/docs/docs/tutorials/ed-3.py +++ b/docs/docs/tutorials/ed-3.py @@ -620,18 +620,21 @@ project.display.pattern(expt_name='hrpt', x_min=38, x_max=41) # %% [markdown] -# #### Save Project State +# #### Save Project State and Submission Report # %% -project.save() +project.save(report=True) # %% [markdown] -# ## Step 5: Summary +# ## Step 5: Report # # This final section shows how to review the results of the analysis. +# +# The saved IUCr submission CIF is available under `reports/.cif` +# inside the project directory. # %% [markdown] -# #### Show Project Summary +# #### Show Project Report # %% -project.summary.show_report() +project.report.show_report() diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb index 6971955d7..6240070d3 100644 --- a/docs/docs/tutorials/ed-5.ipynb +++ b/docs/docs/tutorials/ed-5.ipynb @@ -638,7 +638,7 @@ "id": "53", "metadata": {}, "source": [ - "## Summary\n", + "## Report\n", "\n", "This final section shows how to review the results of the analysis." ] @@ -648,7 +648,7 @@ "id": "54", "metadata": {}, "source": [ - "#### Show Project Summary" + "#### Show Project Report" ] }, { @@ -658,7 +658,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.summary.show_report()" + "project.report.show_report()" ] } ], diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py index cbd235664..425fc0e4c 100644 --- a/docs/docs/tutorials/ed-5.py +++ b/docs/docs/tutorials/ed-5.py @@ -292,12 +292,12 @@ project.display.pattern(expt_name='d20', x_min=42, x_max=52) # %% [markdown] -# ## Summary +# ## Report # # This final section shows how to review the results of the analysis. # %% [markdown] -# #### Show Project Summary +# #### Show Project Report # %% -project.summary.show_report() +project.report.show_report() diff --git a/docs/docs/tutorials/ed-6.ipynb b/docs/docs/tutorials/ed-6.ipynb index c8a7fe4b0..9cce91f3e 100644 --- a/docs/docs/tutorials/ed-6.ipynb +++ b/docs/docs/tutorials/ed-6.ipynb @@ -812,7 +812,7 @@ "id": "74", "metadata": {}, "source": [ - "## Summary\n", + "## Report\n", "\n", "This final section shows how to review the results of the analysis." ] @@ -822,7 +822,7 @@ "id": "75", "metadata": {}, "source": [ - "#### Show Project Summary" + "#### Show Project Report" ] }, { @@ -832,7 +832,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.summary.show_report()" + "project.report.show_report()" ] } ], diff --git a/docs/docs/tutorials/ed-6.py b/docs/docs/tutorials/ed-6.py index 33477e8eb..c31683571 100644 --- a/docs/docs/tutorials/ed-6.py +++ b/docs/docs/tutorials/ed-6.py @@ -339,12 +339,12 @@ project.display.pattern(expt_name='hrpt', x_min=48, x_max=51) # %% [markdown] -# ## Summary +# ## Report # # This final section shows how to review the results of the analysis. # %% [markdown] -# #### Show Project Summary +# #### Show Project Report # %% -project.summary.show_report() +project.report.show_report() diff --git a/docs/docs/tutorials/ed-8.ipynb b/docs/docs/tutorials/ed-8.ipynb index 6bb625de4..f52270236 100644 --- a/docs/docs/tutorials/ed-8.ipynb +++ b/docs/docs/tutorials/ed-8.ipynb @@ -696,7 +696,7 @@ "id": "52", "metadata": {}, "source": [ - "## Summary\n", + "## Report\n", "\n", "This final section shows how to review the results of the analysis." ] @@ -706,7 +706,7 @@ "id": "53", "metadata": {}, "source": [ - "#### Show Project Summary" + "#### Show Project Report" ] }, { @@ -716,7 +716,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.summary.show_report()" + "project.report.show_report()" ] } ], diff --git a/docs/docs/tutorials/ed-8.py b/docs/docs/tutorials/ed-8.py index c158afa95..e665ad266 100644 --- a/docs/docs/tutorials/ed-8.py +++ b/docs/docs/tutorials/ed-8.py @@ -356,12 +356,12 @@ project.display.pattern(expt_name='wish_4_7') # %% [markdown] -# ## Summary +# ## Report # # This final section shows how to review the results of the analysis. # %% [markdown] -# #### Show Project Summary +# #### Show Project Report # %% -project.summary.show_report() +project.report.show_report() diff --git a/docs/docs/user-guide/analysis-workflow/analysis.md b/docs/docs/user-guide/analysis-workflow/analysis.md index f2efb06ac..08de5c80d 100644 --- a/docs/docs/user-guide/analysis-workflow/analysis.md +++ b/docs/docs/user-guide/analysis-workflow/analysis.md @@ -373,4 +373,4 @@ project directory. --- Now that the analysis is finished, you can proceed to the next step: -[Summary](experiment.md). +[Report](report.md). diff --git a/docs/docs/user-guide/analysis-workflow/index.md b/docs/docs/user-guide/analysis-workflow/index.md index 84598210e..79b80497b 100644 --- a/docs/docs/user-guide/analysis-workflow/index.md +++ b/docs/docs/user-guide/analysis-workflow/index.md @@ -9,7 +9,7 @@ flowchart LR b(Model) c(Experiment) d(Analysis) - e(Summary) + e(Report) a --> b b --> c c --> d @@ -28,9 +28,8 @@ flowchart LR - [:material-calculator: Analysis](analysis.md) – **Calculate the diffraction pattern** and **optimize the structural model** by refining its parameters to match experimental measurements. -- [:material-clipboard-text: Summary](summary.md) – Generate a - **report** summarizing the results of the analysis, including refined - parameters. +- [:material-clipboard-text: Report](report.md) – Generate a **report** + summarizing the results of the analysis, including refined parameters. Each step is described in detail in its respective section, guiding users through the **entire diffraction data analysis workflow** in diff --git a/docs/docs/user-guide/analysis-workflow/project.md b/docs/docs/user-guide/analysis-workflow/project.md index 72f77dc18..45fbbe042 100644 --- a/docs/docs/user-guide/analysis-workflow/project.md +++ b/docs/docs/user-guide/analysis-workflow/project.md @@ -86,7 +86,8 @@ The example below illustrates a typical **project structure** for a ├── 📁 analysis - Analysis settings and optional persisted Bayesian arrays. │ ├── 📄 analysis.cif - Settings for data analysis (minimizer, fit mode, constraints, persisted fit state). │ └── 📄 results.h5 - Optional Bayesian sidecar with posterior and predictive arrays. -└── 📄 summary.cif - Summary report after structure refinement. +└── 📁 reports - Optional IUCr submission reports. + └── 📄 La0.5Ba0.5CoO3.cif - Report written by project.save(report=True). @@ -102,7 +103,7 @@ directory, showing the contents of all files in the project. If you save the project right after creating it, the project directory will only contain the `project.cif` file. The other folders and files will be created as you add structures, experiments, and set up the analysis. The - summary folder will be created after the analysis is completed. + reports folder is created only when you call `project.save(report=True)`. ### 1. project.cif diff --git a/docs/docs/user-guide/analysis-workflow/summary.md b/docs/docs/user-guide/analysis-workflow/report.md similarity index 55% rename from docs/docs/user-guide/analysis-workflow/summary.md rename to docs/docs/user-guide/analysis-workflow/report.md index 34a89b61f..18d5d00e4 100644 --- a/docs/docs/user-guide/analysis-workflow/summary.md +++ b/docs/docs/user-guide/analysis-workflow/report.md @@ -2,16 +2,16 @@ icon: material/clipboard-text --- -# :material-clipboard-text: Summary +# :material-clipboard-text: Report -The **Summary** section represents the final step in the data processing -workflow. It involves generating a **summary report** that consolidates -the results of the diffraction data analysis, providing a comprehensive +The **Report** section represents the final step in the data processing +workflow. It involves generating a **report** that consolidates the +results of the diffraction data analysis, providing a comprehensive overview of the model refinement process and its outcomes. -## Contents of the Summary Report +## Contents of the Report -The summary report includes key details such as: +The report includes key details such as: - Final refined model parameters – Optimized crystallographic and instrumental parameters. @@ -20,30 +20,36 @@ The summary report includes key details such as: - Graphical representation – Visualization of experimental vs. calculated diffraction patterns. -## Viewing the Summary Report +## Viewing the Report -Users can print the summary report using: +Users can print the report using: ```python -# Generate and print the summary report -project.summary.show_report() +# Generate and print the report +project.report.show_report() ``` -## Saving a Summary +## Saving a Submission Report -Saving the project, as described in the [Project](project.md) section, -will also save the summary report to the `summary.cif` inside the +Regular project saves do not write a report file. To write an IUCr +journal-submission CIF, use: + +```python +project.save(report=True) +``` + +The report is written to `reports/.cif` inside the saved project directory. +## Configuring Saved Reports -## Saving a Submission Report +Report output is controlled by `project.report`, a project-level +configuration category that is saved in `project.cif`. Regular +`project.save()` calls read this configuration and write the selected +report formats. -Regular project saves do not write a report file. To write an IUCr -journal-submission CIF, use: +| Setting | Type | Meaning | +| ----------------------------- | ------ | -------------------------------------------------- | +| `project.report.cif` | `bool` | Write an IUCr submission CIF. | +| `project.report.html` | `bool` | Write an HTML report (enabled by default). | +| `project.report.tex` | `bool` | Write a TeX report bundle. | +| `project.report.pdf` | `bool` | Write a PDF report when a TeX engine is available. | +| `project.report.html_offline` | `bool` | Embed HTML assets instead of using CDN links. | + +HTML output is enabled by default, so saving a project always writes an +HTML report unless you set `project.report.html = False`. Enable +additional formats through their boolean flags: ```python -project.save(report=True) +project.report.cif = True +project.save() +``` + +This writes `reports/.html` (enabled by default) and +`reports/.cif` inside the project directory. + +When `pdf` is enabled but `tex` is not, the intermediate `reports/tex/` +bundle is written only to build the PDF and removed once the PDF is +produced. It is kept if no TeX engine is available so you can compile it +by hand. + +## One-Off Report Saves + +Per-format methods write a report without changing the saved +configuration: + +```python +project.report.save_html() +project.report.save_cif() +project.report.save_tex() +project.report.save_pdf() +``` + +`save_pdf()` always writes the TeX bundle first. If no TeX engine is on +`PATH`, EasyDiffraction leaves the `.tex` and `data/` files under +`reports/tex/`, prints a short install hint, and does not raise. + +HTML reports load Plotly and MathJax from CDNs by default. Set +`project.report.html_offline = True` to make the HTML report usable +without network access; this embeds Plotly in the HTML and copies the +vendored MathJax bundle next to it, adding about 4.5 MB total. + +The command line saves reports from the persisted project configuration +when a fit writes the project back to disk: + +```bash +python -m easydiffraction path/to/project fit ``` -The report is written to `reports/.cif` inside the saved -project directory. +If `project.cif` contains `_report.html true`, `_report.tex true`, or +another enabled report flag, `fit` writes those reports as part of the +normal project save. Use the Python per-format methods above for one-off +exports without changing the saved configuration. /g,""),o=r.firstChild(r.body(r.parse(n,"text/html"))),i=r.node("mjx-assistive-mml",{unselectable:"on",display:this.display?"block":"inline"},[o]);r.setAttribute(r.firstChild(this.typesetRoot),"aria-hidden","true"),r.setStyle(this.typesetRoot,"position","relative"),r.append(this.typesetRoot,i)}this.state(c.STATE.ASSISTIVEMML)}},e}(t)}function d(t){var e;return e=function(t){function e(){for(var e=[],r=0;r0&&o[o.length-1])||6!==i[0]&&2!==i[0])){s=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLAdaptor=void 0;var s=function(t){function e(e){var r=t.call(this,e.document)||this;return r.window=e,r.parser=new e.DOMParser,r}return o(e,t),e.prototype.parse=function(t,e){return void 0===e&&(e="text/html"),this.parser.parseFromString(t,e)},e.prototype.create=function(t,e){return e?this.document.createElementNS(e,t):this.document.createElement(t)},e.prototype.text=function(t){return this.document.createTextNode(t)},e.prototype.head=function(t){return t.head||t},e.prototype.body=function(t){return t.body||t},e.prototype.root=function(t){return t.documentElement||t},e.prototype.doctype=function(t){return t.doctype?""):""},e.prototype.tags=function(t,e,r){void 0===r&&(r=null);var n=r?t.getElementsByTagNameNS(r,e):t.getElementsByTagName(e);return Array.from(n)},e.prototype.getElements=function(t,e){var r,n,o=[];try{for(var s=i(t),a=s.next();!a.done;a=s.next()){var l=a.value;"string"==typeof l?o=o.concat(Array.from(this.document.querySelectorAll(l))):Array.isArray(l)||l instanceof this.window.NodeList||l instanceof this.window.HTMLCollection?o=o.concat(Array.from(l)):o.push(l)}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=s.return)&&n.call(s)}finally{if(r)throw r.error}}return o},e.prototype.contains=function(t,e){return t.contains(e)},e.prototype.parent=function(t){return t.parentNode},e.prototype.append=function(t,e){return t.appendChild(e)},e.prototype.insert=function(t,e){return this.parent(e).insertBefore(t,e)},e.prototype.remove=function(t){return this.parent(t).removeChild(t)},e.prototype.replace=function(t,e){return this.parent(e).replaceChild(t,e)},e.prototype.clone=function(t){return t.cloneNode(!0)},e.prototype.split=function(t,e){return t.splitText(e)},e.prototype.next=function(t){return t.nextSibling},e.prototype.previous=function(t){return t.previousSibling},e.prototype.firstChild=function(t){return t.firstChild},e.prototype.lastChild=function(t){return t.lastChild},e.prototype.childNodes=function(t){return Array.from(t.childNodes)},e.prototype.childNode=function(t,e){return t.childNodes[e]},e.prototype.kind=function(t){var e=t.nodeType;return 1===e||3===e||8===e?t.nodeName.toLowerCase():""},e.prototype.value=function(t){return t.nodeValue||""},e.prototype.textContent=function(t){return t.textContent},e.prototype.innerHTML=function(t){return t.innerHTML},e.prototype.outerHTML=function(t){return t.outerHTML},e.prototype.serializeXML=function(t){return(new this.window.XMLSerializer).serializeToString(t)},e.prototype.setAttribute=function(t,e,r,n){return void 0===n&&(n=null),n?(e=n.replace(/.*\//,"")+":"+e.replace(/^.*:/,""),t.setAttributeNS(n,e,r)):t.setAttribute(e,r)},e.prototype.getAttribute=function(t,e){return t.getAttribute(e)},e.prototype.removeAttribute=function(t,e){return t.removeAttribute(e)},e.prototype.hasAttribute=function(t,e){return t.hasAttribute(e)},e.prototype.allAttributes=function(t){return Array.from(t.attributes).map((function(t){return{name:t.name,value:t.value}}))},e.prototype.addClass=function(t,e){t.classList?t.classList.add(e):t.className=(t.className+" "+e).trim()},e.prototype.removeClass=function(t,e){t.classList?t.classList.remove(e):t.className=t.className.split(/ /).filter((function(t){return t!==e})).join(" ")},e.prototype.hasClass=function(t,e){return t.classList?t.classList.contains(e):t.className.split(/ /).indexOf(e)>=0},e.prototype.setStyle=function(t,e,r){t.style[e]=r},e.prototype.getStyle=function(t,e){return t.style[e]},e.prototype.allStyles=function(t){return t.style.cssText},e.prototype.insertRules=function(t,e){var r,n;try{for(var o=i(e.reverse()),s=o.next();!s.done;s=o.next()){var a=s.value;try{t.sheet.insertRule(a,0)}catch(t){console.warn("MathJax: can't insert css rule '".concat(a,"': ").concat(t.message))}}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},e.prototype.fontSize=function(t){var e=this.window.getComputedStyle(t);return parseFloat(e.fontSize)},e.prototype.fontFamily=function(t){return this.window.getComputedStyle(t).fontFamily||""},e.prototype.nodeSize=function(t,e,r){if(void 0===e&&(e=1),void 0===r&&(r=!1),r&&t.getBBox){var n=t.getBBox();return[n.width/e,n.height/e]}return[t.offsetWidth/e,t.offsetHeight/e]},e.prototype.nodeBBox=function(t){var e=t.getBoundingClientRect();return{left:e.left,right:e.right,top:e.top,bottom:e.bottom}},e}(r(5009).AbstractDOMAdaptor);e.HTMLAdaptor=s},6191:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.browserAdaptor=void 0;var n=r(444);e.browserAdaptor=function(){return new n.HTMLAdaptor(window)}},9515:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MathJax=e.combineWithMathJax=e.combineDefaults=e.combineConfig=e.isObject=void 0;var o=r(3282);function i(t){return"object"==typeof t&&null!==t}function s(t,e){var r,o;try{for(var a=n(Object.keys(e)),l=a.next();!l.done;l=a.next()){var c=l.value;"__esModule"!==c&&(!i(t[c])||!i(e[c])||e[c]instanceof Promise?null!==e[c]&&void 0!==e[c]&&(t[c]=e[c]):s(t[c],e[c]))}}catch(t){r={error:t}}finally{try{l&&!l.done&&(o=a.return)&&o.call(a)}finally{if(r)throw r.error}}return t}e.isObject=i,e.combineConfig=s,e.combineDefaults=function t(e,r,o){var s,a;e[r]||(e[r]={}),e=e[r];try{for(var l=n(Object.keys(o)),c=l.next();!c.done;c=l.next()){var u=c.value;i(e[u])&&i(o[u])?t(e,u,o[u]):null==e[u]&&null!=o[u]&&(e[u]=o[u])}}catch(t){s={error:t}}finally{try{c&&!c.done&&(a=l.return)&&a.call(l)}finally{if(s)throw s.error}}return e},e.combineWithMathJax=function(t){return s(e.MathJax,t)},void 0===r.g.MathJax&&(r.g.MathJax={}),r.g.MathJax.version||(r.g.MathJax={version:o.VERSION,_:{},config:r.g.MathJax}),e.MathJax=r.g.MathJax},235:function(t,e,r){var n,o,i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CONFIG=e.MathJax=e.Loader=e.PathFilters=e.PackageError=e.Package=void 0;var s=r(9515),a=r(265),l=r(265);Object.defineProperty(e,"Package",{enumerable:!0,get:function(){return l.Package}}),Object.defineProperty(e,"PackageError",{enumerable:!0,get:function(){return l.PackageError}});var c,u=r(7525);if(e.PathFilters={source:function(t){return e.CONFIG.source.hasOwnProperty(t.name)&&(t.name=e.CONFIG.source[t.name]),!0},normalize:function(t){var e=t.name;return e.match(/^(?:[a-z]+:\/)?\/|[a-z]:\\|\[/i)||(t.name="[mathjax]/"+e.replace(/^\.\//,"")),t.addExtension&&!e.match(/\.[^\/]+$/)&&(t.name+=".js"),!0},prefix:function(t){for(var r;(r=t.name.match(/^\[([^\]]*)\]/))&&e.CONFIG.paths.hasOwnProperty(r[1]);)t.name=e.CONFIG.paths[r[1]]+t.name.substr(r[0].length);return!0}},function(t){var r=s.MathJax.version;t.versions=new Map,t.ready=function(){for(var t,e,r=[],n=0;n=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractDOMAdaptor=void 0;var n=function(){function t(t){void 0===t&&(t=null),this.document=t}return t.prototype.node=function(t,e,n,o){var i,s;void 0===e&&(e={}),void 0===n&&(n=[]);var a=this.create(t,o);this.setAttributes(a,e);try{for(var l=r(n),c=l.next();!c.done;c=l.next()){var u=c.value;this.append(a,u)}}catch(t){i={error:t}}finally{try{c&&!c.done&&(s=l.return)&&s.call(l)}finally{if(i)throw i.error}}return a},t.prototype.setAttributes=function(t,e){var n,o,i,s,a,l;if(e.style&&"string"!=typeof e.style)try{for(var c=r(Object.keys(e.style)),u=c.next();!u.done;u=c.next()){var p=u.value;this.setStyle(t,p.replace(/-([a-z])/g,(function(t,e){return e.toUpperCase()})),e.style[p])}}catch(t){n={error:t}}finally{try{u&&!u.done&&(o=c.return)&&o.call(c)}finally{if(n)throw n.error}}if(e.properties)try{for(var h=r(Object.keys(e.properties)),f=h.next();!f.done;f=h.next()){t[p=f.value]=e.properties[p]}}catch(t){i={error:t}}finally{try{f&&!f.done&&(s=h.return)&&s.call(h)}finally{if(i)throw i.error}}try{for(var d=r(Object.keys(e)),m=d.next();!m.done;m=d.next()){"style"===(p=m.value)&&"string"!=typeof e.style||"properties"===p||this.setAttribute(t,p,e[p])}}catch(t){a={error:t}}finally{try{m&&!m.done&&(l=d.return)&&l.call(d)}finally{if(a)throw a.error}}},t.prototype.replace=function(t,e){return this.insert(t,e),this.remove(e),e},t.prototype.childNode=function(t,e){return this.childNodes(t)[e]},t.prototype.allClasses=function(t){var e=this.getAttribute(t,"class");return e?e.replace(/ +/g," ").replace(/^ /,"").replace(/ $/,"").split(/ /):[]},t}();e.AbstractDOMAdaptor=n},3494:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractFindMath=void 0;var n=r(7233),o=function(){function t(t){var e=this.constructor;this.options=(0,n.userOptions)((0,n.defaultOptions)({},e.OPTIONS),t)}return t.OPTIONS={},t}();e.AbstractFindMath=o},3670:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractHandler=void 0;var i=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e}(r(5722).AbstractMathDocument),s=function(){function t(t,e){void 0===e&&(e=5),this.documentClass=i,this.adaptor=t,this.priority=e}return Object.defineProperty(t.prototype,"name",{get:function(){return this.constructor.NAME},enumerable:!1,configurable:!0}),t.prototype.handlesDocument=function(t){return!1},t.prototype.create=function(t,e){return new this.documentClass(t,this.adaptor,e)},t.NAME="generic",t}();e.AbstractHandler=s},805:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.HandlerList=void 0;var s=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.register=function(t){return this.add(t,t.priority)},e.prototype.unregister=function(t){this.remove(t)},e.prototype.handlesDocument=function(t){var e,r;try{for(var n=i(this),o=n.next();!o.done;o=n.next()){var s=o.value.item;if(s.handlesDocument(t))return s}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}throw new Error("Can't find handler for document")},e.prototype.document=function(t,e){return void 0===e&&(e=null),this.handlesDocument(t).create(t,e)},e}(r(8666).PrioritizedList);e.HandlerList=s},9206:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractInputJax=void 0;var n=r(7233),o=r(7525),i=function(){function t(t){void 0===t&&(t={}),this.adaptor=null,this.mmlFactory=null;var e=this.constructor;this.options=(0,n.userOptions)((0,n.defaultOptions)({},e.OPTIONS),t),this.preFilters=new o.FunctionList,this.postFilters=new o.FunctionList}return Object.defineProperty(t.prototype,"name",{get:function(){return this.constructor.NAME},enumerable:!1,configurable:!0}),t.prototype.setAdaptor=function(t){this.adaptor=t},t.prototype.setMmlFactory=function(t){this.mmlFactory=t},t.prototype.initialize=function(){},t.prototype.reset=function(){for(var t=[],e=0;e=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=e&&a.item.renderDoc(t))return}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},e.prototype.renderMath=function(t,e,r){var n,o;void 0===r&&(r=h.STATE.UNPROCESSED);try{for(var s=i(this.items),a=s.next();!a.done;a=s.next()){var l=a.value;if(l.priority>=r&&l.item.renderMath(t,e))return}}catch(t){n={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(n)throw n.error}}},e.prototype.renderConvert=function(t,e,r){var n,o;void 0===r&&(r=h.STATE.LAST);try{for(var s=i(this.items),a=s.next();!a.done;a=s.next()){var l=a.value;if(l.priority>r)return;if(l.item.convert&&l.item.renderMath(t,e))return}}catch(t){n={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(n)throw n.error}}},e.prototype.findID=function(t){var e,r;try{for(var n=i(this.items),o=n.next();!o.done;o=n.next()){var s=o.value;if(s.item.id===t)return s.item}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}return null},e}(r(8666).PrioritizedList);e.RenderList=m,e.resetOptions={all:!1,processed:!1,inputJax:null,outputJax:null},e.resetAllOptions={all:!0,processed:!0,inputJax:[],outputJax:[]};var y=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.compile=function(t){return null},e}(c.AbstractInputJax),g=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.typeset=function(t,e){return void 0===e&&(e=null),null},e.prototype.escaped=function(t,e){return null},e}(u.AbstractOutputJax),b=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e}(p.AbstractMathList),v=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e}(h.AbstractMathItem),_=function(){function t(e,r,n){var o=this,i=this.constructor;this.document=e,this.options=(0,l.userOptions)((0,l.defaultOptions)({},i.OPTIONS),n),this.math=new(this.options.MathList||b),this.renderActions=m.create(this.options.renderActions),this.processed=new t.ProcessBits,this.outputJax=this.options.OutputJax||new g;var s=this.options.InputJax||[new y];Array.isArray(s)||(s=[s]),this.inputJax=s,this.adaptor=r,this.outputJax.setAdaptor(r),this.inputJax.map((function(t){return t.setAdaptor(r)})),this.mmlFactory=this.options.MmlFactory||new f.MmlFactory,this.inputJax.map((function(t){return t.setMmlFactory(o.mmlFactory)})),this.outputJax.initialize(),this.inputJax.map((function(t){return t.initialize()}))}return Object.defineProperty(t.prototype,"kind",{get:function(){return this.constructor.KIND},enumerable:!1,configurable:!0}),t.prototype.addRenderAction=function(t){for(var e=[],r=1;r=r&&this.state(r-1),t.renderActions.renderMath(this,t,r)},t.prototype.convert=function(t,r){void 0===r&&(r=e.STATE.LAST),t.renderActions.renderConvert(this,t,r)},t.prototype.compile=function(t){this.state()=e.STATE.INSERTED&&this.removeFromDocument(r),t=e.STATE.TYPESET&&(this.outputData={}),t=e.STATE.COMPILED&&(this.inputData={}),this._state=t),this._state},t.prototype.reset=function(t){void 0===t&&(t=!1),this.state(e.STATE.UNPROCESSED,t)},t}();e.AbstractMathItem=r,e.STATE={UNPROCESSED:0,FINDMATH:10,COMPILED:20,CONVERT:100,METRICS:110,RERENDER:125,TYPESET:150,INSERTED:200,LAST:1e4},e.newState=function(t,r){if(t in e.STATE)throw Error("State "+t+" already exists");e.STATE[t]=r}},9e3:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractMathList=void 0;var i=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.isBefore=function(t,e){return t.start.i=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.Attributes=e.INHERIT=void 0,e.INHERIT="_inherit_";var n=function(){function t(t,e){this.global=e,this.defaults=Object.create(e),this.inherited=Object.create(this.defaults),this.attributes=Object.create(this.inherited),Object.assign(this.defaults,t)}return t.prototype.set=function(t,e){this.attributes[t]=e},t.prototype.setList=function(t){Object.assign(this.attributes,t)},t.prototype.get=function(t){var r=this.attributes[t];return r===e.INHERIT&&(r=this.global[t]),r},t.prototype.getExplicit=function(t){if(this.attributes.hasOwnProperty(t))return this.attributes[t]},t.prototype.getList=function(){for(var t,e,n=[],o=0;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MathMLVisitor=void 0;var s=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.document=null,e}return o(e,t),e.prototype.visitTree=function(t,e){this.document=e;var r=e.createElement("top");return this.visitNode(t,r),this.document=null,r.firstChild},e.prototype.visitTextNode=function(t,e){e.appendChild(this.document.createTextNode(t.getText()))},e.prototype.visitXMLNode=function(t,e){e.appendChild(t.getXML().cloneNode(!0))},e.prototype.visitInferredMrowNode=function(t,e){var r,n;try{for(var o=i(t.childNodes),s=o.next();!s.done;s=o.next()){var a=s.value;this.visitNode(a,e)}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},e.prototype.visitDefault=function(t,e){var r,n,o=this.document.createElement(t.kind);this.addAttributes(t,o);try{for(var s=i(t.childNodes),a=s.next();!a.done;a=s.next()){var l=a.value;this.visitNode(l,o)}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=s.return)&&n.call(s)}finally{if(r)throw r.error}}e.appendChild(o)},e.prototype.addAttributes=function(t,e){var r,n,o=t.attributes,s=o.getExplicitNames();try{for(var a=i(s),l=a.next();!l.done;l=a.next()){var c=l.value;e.setAttribute(c,o.getExplicit(c).toString())}}catch(t){r={error:t}}finally{try{l&&!l.done&&(n=a.return)&&n.call(a)}finally{if(r)throw r.error}}},e}(r(6325).MmlVisitor);e.MathMLVisitor=s},3909:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.MmlFactory=void 0;var i=r(7860),s=r(6336),a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"MML",{get:function(){return this.node},enumerable:!1,configurable:!0}),e.defaultNodes=s.MML,e}(i.AbstractNodeFactory);e.MmlFactory=a},9007:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},a=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.XMLNode=e.TextNode=e.AbstractMmlEmptyNode=e.AbstractMmlBaseNode=e.AbstractMmlLayoutNode=e.AbstractMmlTokenNode=e.AbstractMmlNode=e.indentAttributes=e.TEXCLASSNAMES=e.TEXCLASS=void 0;var l=r(91),c=r(4596);e.TEXCLASS={ORD:0,OP:1,BIN:2,REL:3,OPEN:4,CLOSE:5,PUNCT:6,INNER:7,VCENTER:8,NONE:-1},e.TEXCLASSNAMES=["ORD","OP","BIN","REL","OPEN","CLOSE","PUNCT","INNER","VCENTER"];var u=["","thinmathspace","mediummathspace","thickmathspace"],p=[[0,-1,2,3,0,0,0,1],[-1,-1,0,3,0,0,0,1],[2,2,0,0,2,0,0,2],[3,3,0,0,3,0,0,3],[0,0,0,0,0,0,0,0],[0,-1,2,3,0,0,0,1],[1,1,0,1,1,1,1,1],[1,-1,2,3,1,0,1,1]];e.indentAttributes=["indentalign","indentalignfirst","indentshift","indentshiftfirst"];var h=function(t){function r(e,r,n){void 0===r&&(r={}),void 0===n&&(n=[]);var o=t.call(this,e)||this;return o.prevClass=null,o.prevLevel=null,o.texclass=null,o.arity<0&&(o.childNodes=[e.create("inferredMrow")],o.childNodes[0].parent=o),o.setChildren(n),o.attributes=new l.Attributes(e.getNodeClass(o.kind).defaults,e.getNodeClass("math").defaults),o.attributes.setList(r),o}return o(r,t),r.prototype.copy=function(t){var e,r,n,o;void 0===t&&(t=!1);var a=this.factory.create(this.kind);if(a.properties=i({},this.properties),this.attributes){var l=this.attributes.getAllAttributes();try{for(var c=s(Object.keys(l)),u=c.next();!u.done;u=c.next()){var p=u.value;("id"!==p||t)&&a.attributes.set(p,l[p])}}catch(t){e={error:t}}finally{try{u&&!u.done&&(r=c.return)&&r.call(c)}finally{if(e)throw e.error}}}if(this.childNodes&&this.childNodes.length){var h=this.childNodes;1===h.length&&h[0].isInferred&&(h=h[0].childNodes);try{for(var f=s(h),d=f.next();!d.done;d=f.next()){var m=d.value;m?a.appendChild(m.copy()):a.childNodes.push(null)}}catch(t){n={error:t}}finally{try{d&&!d.done&&(o=f.return)&&o.call(f)}finally{if(n)throw n.error}}}return a},Object.defineProperty(r.prototype,"texClass",{get:function(){return this.texclass},set:function(t){this.texclass=t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isToken",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isEmbellished",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isSpacelike",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"linebreakContainer",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"hasNewLine",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"arity",{get:function(){return 1/0},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isInferred",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"Parent",{get:function(){for(var t=this.parent;t&&t.notParent;)t=t.Parent;return t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"notParent",{get:function(){return!1},enumerable:!1,configurable:!0}),r.prototype.setChildren=function(e){return this.arity<0?this.childNodes[0].setChildren(e):t.prototype.setChildren.call(this,e)},r.prototype.appendChild=function(e){var r,n,o=this;if(this.arity<0)return this.childNodes[0].appendChild(e),e;if(e.isInferred){if(this.arity===1/0)return e.childNodes.forEach((function(e){return t.prototype.appendChild.call(o,e)})),e;var i=e;(e=this.factory.create("mrow")).setChildren(i.childNodes),e.attributes=i.attributes;try{for(var a=s(i.getPropertyNames()),l=a.next();!l.done;l=a.next()){var c=l.value;e.setProperty(c,i.getProperty(c))}}catch(t){r={error:t}}finally{try{l&&!l.done&&(n=a.return)&&n.call(a)}finally{if(r)throw r.error}}}return t.prototype.appendChild.call(this,e)},r.prototype.replaceChild=function(e,r){return this.arity<0?(this.childNodes[0].replaceChild(e,r),e):t.prototype.replaceChild.call(this,e,r)},r.prototype.core=function(){return this},r.prototype.coreMO=function(){return this},r.prototype.coreIndex=function(){return 0},r.prototype.childPosition=function(){for(var t,e,r=this,n=r.parent;n&&n.notParent;)r=n,n=n.parent;if(n){var o=0;try{for(var i=s(n.childNodes),a=i.next();!a.done;a=i.next()){if(a.value===r)return o;o++}}catch(e){t={error:e}}finally{try{a&&!a.done&&(e=i.return)&&e.call(i)}finally{if(t)throw t.error}}}return null},r.prototype.setTeXclass=function(t){return this.getPrevClass(t),null!=this.texClass?this:t},r.prototype.updateTeXclass=function(t){t&&(this.prevClass=t.prevClass,this.prevLevel=t.prevLevel,t.prevClass=t.prevLevel=null,this.texClass=t.texClass)},r.prototype.getPrevClass=function(t){t&&(this.prevClass=t.texClass,this.prevLevel=t.attributes.get("scriptlevel"))},r.prototype.texSpacing=function(){var t=null!=this.prevClass?this.prevClass:e.TEXCLASS.NONE,r=this.texClass||e.TEXCLASS.ORD;if(t===e.TEXCLASS.NONE||r===e.TEXCLASS.NONE)return"";t===e.TEXCLASS.VCENTER&&(t=e.TEXCLASS.ORD),r===e.TEXCLASS.VCENTER&&(r=e.TEXCLASS.ORD);var n=p[t][r];return(this.prevLevel>0||this.attributes.get("scriptlevel")>0)&&n>=0?"":u[Math.abs(n)]},r.prototype.hasSpacingAttributes=function(){return this.isEmbellished&&this.coreMO().hasSpacingAttributes()},r.prototype.setInheritedAttributes=function(t,e,n,o){var i,l;void 0===t&&(t={}),void 0===e&&(e=!1),void 0===n&&(n=0),void 0===o&&(o=!1);var c=this.attributes.getAllDefaults();try{for(var u=s(Object.keys(t)),p=u.next();!p.done;p=u.next()){var h=p.value;if(c.hasOwnProperty(h)||r.alwaysInherit.hasOwnProperty(h)){var f=a(t[h],2),d=f[0],m=f[1];((r.noInherit[d]||{})[this.kind]||{})[h]||this.attributes.setInherited(h,m)}}}catch(t){i={error:t}}finally{try{p&&!p.done&&(l=u.return)&&l.call(u)}finally{if(i)throw i.error}}void 0===this.attributes.getExplicit("displaystyle")&&this.attributes.setInherited("displaystyle",e),void 0===this.attributes.getExplicit("scriptlevel")&&this.attributes.setInherited("scriptlevel",n),o&&this.setProperty("texprimestyle",o);var y=this.arity;if(y>=0&&y!==1/0&&(1===y&&0===this.childNodes.length||1!==y&&this.childNodes.length!==y))if(y=0&&e!==1/0&&(1===e&&0===this.childNodes.length||1!==e&&this.childNodes.length!==e)&&this.mError('Wrong number of children for "'+this.kind+'" node',t,!0),this.verifyChildren(t)}},r.prototype.verifyAttributes=function(t){var e,r;if(t.checkAttributes){var n=this.attributes,o=[];try{for(var i=s(n.getExplicitNames()),a=i.next();!a.done;a=i.next()){var l=a.value;"data-"===l.substr(0,5)||void 0!==n.getDefault(l)||l.match(/^(?:class|style|id|(?:xlink:)?href)$/)||o.push(l)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}o.length&&this.mError("Unknown attributes for "+this.kind+" node: "+o.join(", "),t)}},r.prototype.verifyChildren=function(t){var e,r;try{for(var n=s(this.childNodes),o=n.next();!o.done;o=n.next()){o.value.verifyTree(t)}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}},r.prototype.mError=function(t,e,r){if(void 0===r&&(r=!1),this.parent&&this.parent.isKind("merror"))return null;var n=this.factory.create("merror");if(n.attributes.set("data-mjx-message",t),e.fullErrors||r){var o=this.factory.create("mtext"),i=this.factory.create("text");i.setText(e.fullErrors?t:this.kind),o.appendChild(i),n.appendChild(o),this.parent.replaceChild(n,this)}else this.parent.replaceChild(n,this),n.appendChild(this);return n},r.defaults={mathbackground:l.INHERIT,mathcolor:l.INHERIT,mathsize:l.INHERIT,dir:l.INHERIT},r.noInherit={mstyle:{mpadded:{width:!0,height:!0,depth:!0,lspace:!0,voffset:!0},mtable:{width:!0,height:!0,depth:!0,align:!0}},maligngroup:{mrow:{groupalign:!0},mtable:{groupalign:!0}}},r.alwaysInherit={scriptminsize:!0,scriptsizemultiplier:!0},r.verifyDefaults={checkArity:!0,checkAttributes:!1,fullErrors:!1,fixMmultiscripts:!0,fixMtables:!0},r}(c.AbstractNode);e.AbstractMmlNode=h;var f=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"isToken",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.getText=function(){var t,e,r="";try{for(var n=s(this.childNodes),o=n.next();!o.done;o=n.next()){var i=o.value;i instanceof g&&(r+=i.getText())}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}return r},e.prototype.setChildInheritedAttributes=function(t,e,r,n){var o,i;try{for(var a=s(this.childNodes),l=a.next();!l.done;l=a.next()){var c=l.value;c instanceof h&&c.setInheritedAttributes(t,e,r,n)}}catch(t){o={error:t}}finally{try{l&&!l.done&&(i=a.return)&&i.call(a)}finally{if(o)throw o.error}}},e.prototype.walkTree=function(t,e){var r,n;t(this,e);try{for(var o=s(this.childNodes),i=o.next();!i.done;i=o.next()){var a=i.value;a instanceof h&&a.walkTree(t,e)}}catch(t){r={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}return e},e.defaults=i(i({},h.defaults),{mathvariant:"normal",mathsize:l.INHERIT}),e}(h);e.AbstractMmlTokenNode=f;var d=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"isSpacelike",{get:function(){return this.childNodes[0].isSpacelike},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isEmbellished",{get:function(){return this.childNodes[0].isEmbellished},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"arity",{get:function(){return-1},enumerable:!1,configurable:!0}),e.prototype.core=function(){return this.childNodes[0]},e.prototype.coreMO=function(){return this.childNodes[0].coreMO()},e.prototype.setTeXclass=function(t){return t=this.childNodes[0].setTeXclass(t),this.updateTeXclass(this.childNodes[0]),t},e.defaults=h.defaults,e}(h);e.AbstractMmlLayoutNode=d;var m=function(t){function r(){return null!==t&&t.apply(this,arguments)||this}return o(r,t),Object.defineProperty(r.prototype,"isEmbellished",{get:function(){return this.childNodes[0].isEmbellished},enumerable:!1,configurable:!0}),r.prototype.core=function(){return this.childNodes[0]},r.prototype.coreMO=function(){return this.childNodes[0].coreMO()},r.prototype.setTeXclass=function(t){var r,n;this.getPrevClass(t),this.texClass=e.TEXCLASS.ORD;var o=this.childNodes[0];o?this.isEmbellished||o.isKind("mi")?(t=o.setTeXclass(t),this.updateTeXclass(this.core())):(o.setTeXclass(null),t=this):t=this;try{for(var i=s(this.childNodes.slice(1)),a=i.next();!a.done;a=i.next()){var l=a.value;l&&l.setTeXclass(null)}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}return t},r.defaults=h.defaults,r}(h);e.AbstractMmlBaseNode=m;var y=function(t){function r(){return null!==t&&t.apply(this,arguments)||this}return o(r,t),Object.defineProperty(r.prototype,"isToken",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isEmbellished",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isSpacelike",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"linebreakContainer",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"hasNewLine",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"arity",{get:function(){return 0},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"isInferred",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"notParent",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"Parent",{get:function(){return this.parent},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"texClass",{get:function(){return e.TEXCLASS.NONE},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"prevClass",{get:function(){return e.TEXCLASS.NONE},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"prevLevel",{get:function(){return 0},enumerable:!1,configurable:!0}),r.prototype.hasSpacingAttributes=function(){return!1},Object.defineProperty(r.prototype,"attributes",{get:function(){return null},enumerable:!1,configurable:!0}),r.prototype.core=function(){return this},r.prototype.coreMO=function(){return this},r.prototype.coreIndex=function(){return 0},r.prototype.childPosition=function(){return 0},r.prototype.setTeXclass=function(t){return t},r.prototype.texSpacing=function(){return""},r.prototype.setInheritedAttributes=function(t,e,r,n){},r.prototype.inheritAttributesFrom=function(t){},r.prototype.verifyTree=function(t){},r.prototype.mError=function(t,e,r){return void 0===r&&(r=!1),null},r}(c.AbstractEmptyNode);e.AbstractMmlEmptyNode=y;var g=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.text="",e}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"text"},enumerable:!1,configurable:!0}),e.prototype.getText=function(){return this.text},e.prototype.setText=function(t){return this.text=t,this},e.prototype.copy=function(){return this.factory.create(this.kind).setText(this.getText())},e.prototype.toString=function(){return this.text},e}(y);e.TextNode=g;var b=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.xml=null,e.adaptor=null,e}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"XML"},enumerable:!1,configurable:!0}),e.prototype.getXML=function(){return this.xml},e.prototype.setXML=function(t,e){return void 0===e&&(e=null),this.xml=t,this.adaptor=e,this},e.prototype.getSerializedXML=function(){return this.adaptor.serializeXML(this.xml)},e.prototype.copy=function(){return this.factory.create(this.kind).setXML(this.adaptor.clone(this.xml))},e.prototype.toString=function(){return"XML data"},e}(y);e.XMLNode=b},3948:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;rthis.childNodes.length&&(t=1),this.attributes.set("selection",t)},e.defaults=i(i({},s.AbstractMmlNode.defaults),{actiontype:"toggle",selection:1}),e}(s.AbstractMmlNode);e.MmlMaction=a},142:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlMfenced=void 0;var a=r(9007),l=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.texclass=a.TEXCLASS.INNER,e.separators=[],e.open=null,e.close=null,e}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mfenced"},enumerable:!1,configurable:!0}),e.prototype.setTeXclass=function(t){this.getPrevClass(t),this.open&&(t=this.open.setTeXclass(t)),this.childNodes[0]&&(t=this.childNodes[0].setTeXclass(t));for(var e=1,r=this.childNodes.length;e=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlMfrac=void 0;var a=r(9007),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mfrac"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"arity",{get:function(){return 2},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"linebreakContainer",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.setTeXclass=function(t){var e,r;this.getPrevClass(t);try{for(var n=s(this.childNodes),o=n.next();!o.done;o=n.next()){o.value.setTeXclass(null)}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}return this},e.prototype.setChildInheritedAttributes=function(t,e,r,n){(!e||r>0)&&r++,this.childNodes[0].setInheritedAttributes(t,!1,r,n),this.childNodes[1].setInheritedAttributes(t,!1,r,!0)},e.defaults=i(i({},a.AbstractMmlBaseNode.defaults),{linethickness:"medium",numalign:"center",denomalign:"center",bevelled:!1}),e}(a.AbstractMmlBaseNode);e.MmlMfrac=l},3985:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r1&&r.match(e.operatorName)&&"normal"===this.attributes.get("mathvariant")&&void 0===this.getProperty("autoOP")&&void 0===this.getProperty("texClass")&&(this.texClass=s.TEXCLASS.OP,this.setProperty("autoOP",!0)),this},e.defaults=i({},s.AbstractMmlTokenNode.defaults),e.operatorName=/^[a-z][a-z0-9]*$/i,e.singleCharacter=/^[\uD800-\uDBFF]?.[\u0300-\u036F\u1AB0-\u1ABE\u1DC0-\u1DFF\u20D0-\u20EF]*$/,e}(s.AbstractMmlTokenNode);e.MmlMi=a},6405:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlMo=void 0;var l=r(9007),c=r(4082),u=r(505),p=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._texClass=null,e.lspace=5/18,e.rspace=5/18,e}return o(e,t),Object.defineProperty(e.prototype,"texClass",{get:function(){if(null===this._texClass){var t=this.getText(),e=s(this.handleExplicitForm(this.getForms()),3),r=e[0],n=e[1],o=e[2],i=this.constructor.OPTABLE,a=i[r][t]||i[n][t]||i[o][t];return a?a[2]:l.TEXCLASS.REL}return this._texClass},set:function(t){this._texClass=t},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"kind",{get:function(){return"mo"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isEmbellished",{get:function(){return!0},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"hasNewLine",{get:function(){return"newline"===this.attributes.get("linebreak")},enumerable:!1,configurable:!0}),e.prototype.coreParent=function(){for(var t=this,e=this,r=this.factory.getNodeClass("math");e&&e.isEmbellished&&e.coreMO()===this&&!(e instanceof r);)t=e,e=e.parent;return t},e.prototype.coreText=function(t){if(!t)return"";if(t.isEmbellished)return t.coreMO().getText();for(;((t.isKind("mrow")||t.isKind("TeXAtom")&&t.texClass!==l.TEXCLASS.VCENTER||t.isKind("mstyle")||t.isKind("mphantom"))&&1===t.childNodes.length||t.isKind("munderover"))&&t.childNodes[0];)t=t.childNodes[0];return t.isToken?t.getText():""},e.prototype.hasSpacingAttributes=function(){return this.attributes.isSet("lspace")||this.attributes.isSet("rspace")},Object.defineProperty(e.prototype,"isAccent",{get:function(){var t=!1,e=this.coreParent().parent;if(e){var r=e.isKind("mover")?e.childNodes[e.over].coreMO()?"accent":"":e.isKind("munder")?e.childNodes[e.under].coreMO()?"accentunder":"":e.isKind("munderover")?this===e.childNodes[e.over].coreMO()?"accent":this===e.childNodes[e.under].coreMO()?"accentunder":"":"";if(r)t=void 0!==e.attributes.getExplicit(r)?t:this.attributes.get("accent")}return t},enumerable:!1,configurable:!0}),e.prototype.setTeXclass=function(t){var e=this.attributes.getList("form","fence"),r=e.form,n=e.fence;return void 0===this.getProperty("texClass")&&(this.attributes.isSet("lspace")||this.attributes.isSet("rspace"))?null:(n&&this.texClass===l.TEXCLASS.REL&&("prefix"===r&&(this.texClass=l.TEXCLASS.OPEN),"postfix"===r&&(this.texClass=l.TEXCLASS.CLOSE)),this.adjustTeXclass(t))},e.prototype.adjustTeXclass=function(t){var e=this.texClass,r=this.prevClass;if(e===l.TEXCLASS.NONE)return t;if(t?(!t.getProperty("autoOP")||e!==l.TEXCLASS.BIN&&e!==l.TEXCLASS.REL||(r=t.texClass=l.TEXCLASS.ORD),r=this.prevClass=t.texClass||l.TEXCLASS.ORD,this.prevLevel=this.attributes.getInherited("scriptlevel")):r=this.prevClass=l.TEXCLASS.NONE,e!==l.TEXCLASS.BIN||r!==l.TEXCLASS.NONE&&r!==l.TEXCLASS.BIN&&r!==l.TEXCLASS.OP&&r!==l.TEXCLASS.REL&&r!==l.TEXCLASS.OPEN&&r!==l.TEXCLASS.PUNCT)if(r!==l.TEXCLASS.BIN||e!==l.TEXCLASS.REL&&e!==l.TEXCLASS.CLOSE&&e!==l.TEXCLASS.PUNCT){if(e===l.TEXCLASS.BIN){for(var n=this,o=this.parent;o&&o.parent&&o.isEmbellished&&(1===o.childNodes.length||!o.isKind("mrow")&&o.core()===n);)n=o,o=o.parent;o.childNodes[o.childNodes.length-1]===n&&(this.texClass=l.TEXCLASS.ORD)}}else t.texClass=this.prevClass=l.TEXCLASS.ORD;else this.texClass=l.TEXCLASS.ORD;return this},e.prototype.setInheritedAttributes=function(e,r,n,o){void 0===e&&(e={}),void 0===r&&(r=!1),void 0===n&&(n=0),void 0===o&&(o=!1),t.prototype.setInheritedAttributes.call(this,e,r,n,o);var i=this.getText();this.checkOperatorTable(i),this.checkPseudoScripts(i),this.checkPrimes(i),this.checkMathAccent(i)},e.prototype.checkOperatorTable=function(t){var e,r,n=s(this.handleExplicitForm(this.getForms()),3),o=n[0],i=n[1],l=n[2];this.attributes.setInherited("form",o);var u=this.constructor.OPTABLE,p=u[o][t]||u[i][t]||u[l][t];if(p){void 0===this.getProperty("texClass")&&(this.texClass=p[2]);try{for(var h=a(Object.keys(p[3]||{})),f=h.next();!f.done;f=h.next()){var d=f.value;this.attributes.setInherited(d,p[3][d])}}catch(t){e={error:t}}finally{try{f&&!f.done&&(r=h.return)&&r.call(h)}finally{if(e)throw e.error}}this.lspace=(p[0]+1)/18,this.rspace=(p[1]+1)/18}else{var m=(0,c.getRange)(t);if(m){void 0===this.getProperty("texClass")&&(this.texClass=m[2]);var y=this.constructor.MMLSPACING[m[2]];this.lspace=(y[0]+1)/18,this.rspace=(y[1]+1)/18}}},e.prototype.getForms=function(){for(var t=this,e=this.parent,r=this.Parent;r&&r.isEmbellished;)t=e,e=r.parent,r=r.Parent;if(e&&e.isKind("mrow")&&1!==e.nonSpaceLength()){if(e.firstNonSpace()===t)return["prefix","infix","postfix"];if(e.lastNonSpace()===t)return["postfix","infix","prefix"]}return["infix","prefix","postfix"]},e.prototype.handleExplicitForm=function(t){if(this.attributes.isSet("form")){var e=this.attributes.get("form");t=[e].concat(t.filter((function(t){return t!==e})))}return t},e.prototype.checkPseudoScripts=function(t){var e=this.constructor.pseudoScripts;if(t.match(e)){var r=this.coreParent().Parent,n=!r||!(r.isKind("msubsup")&&!r.isKind("msub"));this.setProperty("pseudoscript",n),n&&(this.attributes.setInherited("lspace",0),this.attributes.setInherited("rspace",0))}},e.prototype.checkPrimes=function(t){var e=this.constructor.primes;if(t.match(e)){var r=this.constructor.remapPrimes,n=(0,u.unicodeString)((0,u.unicodeChars)(t).map((function(t){return r[t]})));this.setProperty("primes",n)}},e.prototype.checkMathAccent=function(t){var e=this.Parent;if(void 0===this.getProperty("mathaccent")&&e&&e.isKind("munderover")){var r=e.childNodes[0];if(!r.isEmbellished||r.coreMO()!==this){var n=this.constructor.mathaccents;t.match(n)&&this.setProperty("mathaccent",!0)}}},e.defaults=i(i({},l.AbstractMmlTokenNode.defaults),{form:"infix",fence:!1,separator:!1,lspace:"thickmathspace",rspace:"thickmathspace",stretchy:!1,symmetric:!1,maxsize:"infinity",minsize:"0em",largeop:!1,movablelimits:!1,accent:!1,linebreak:"auto",lineleading:"1ex",linebreakstyle:"before",indentalign:"auto",indentshift:"0",indenttarget:"",indentalignfirst:"indentalign",indentshiftfirst:"indentshift",indentalignlast:"indentalign",indentshiftlast:"indentshift"}),e.MMLSPACING=c.MMLSPACING,e.OPTABLE=c.OPTABLE,e.pseudoScripts=new RegExp(["^[\"'*`","\xaa","\xb0","\xb2-\xb4","\xb9","\xba","\u2018-\u201f","\u2032-\u2037\u2057","\u2070\u2071","\u2074-\u207f","\u2080-\u208e","]+$"].join("")),e.primes=new RegExp(["^[\"'`","\u2018-\u201f","]+$"].join("")),e.remapPrimes={34:8243,39:8242,96:8245,8216:8245,8217:8242,8218:8242,8219:8245,8220:8246,8221:8243,8222:8243,8223:8246},e.mathaccents=new RegExp(["^[","\xb4\u0301\u02ca","`\u0300\u02cb","\xa8\u0308","~\u0303\u02dc","\xaf\u0304\u02c9","\u02d8\u0306","\u02c7\u030c","^\u0302\u02c6","\u2192\u20d7","\u02d9\u0307","\u02da\u030a","\u20db","\u20dc","]$"].join("")),e}(l.AbstractMmlTokenNode);e.MmlMo=p},7238:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlInferredMrow=e.MmlMrow=void 0;var a=r(9007),l=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._core=null,e}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mrow"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isSpacelike",{get:function(){var t,e;try{for(var r=s(this.childNodes),n=r.next();!n.done;n=r.next()){if(!n.value.isSpacelike)return!1}}catch(e){t={error:e}}finally{try{n&&!n.done&&(e=r.return)&&e.call(r)}finally{if(t)throw t.error}}return!0},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isEmbellished",{get:function(){var t,e,r=!1,n=0;try{for(var o=s(this.childNodes),i=o.next();!i.done;i=o.next()){var a=i.value;if(a)if(a.isEmbellished){if(r)return!1;r=!0,this._core=n}else if(!a.isSpacelike)return!1;n++}}catch(e){t={error:e}}finally{try{i&&!i.done&&(e=o.return)&&e.call(o)}finally{if(t)throw t.error}}return r},enumerable:!1,configurable:!0}),e.prototype.core=function(){return this.isEmbellished&&null!=this._core?this.childNodes[this._core]:this},e.prototype.coreMO=function(){return this.isEmbellished&&null!=this._core?this.childNodes[this._core].coreMO():this},e.prototype.nonSpaceLength=function(){var t,e,r=0;try{for(var n=s(this.childNodes),o=n.next();!o.done;o=n.next()){var i=o.value;i&&!i.isSpacelike&&r++}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}return r},e.prototype.firstNonSpace=function(){var t,e;try{for(var r=s(this.childNodes),n=r.next();!n.done;n=r.next()){var o=n.value;if(o&&!o.isSpacelike)return o}}catch(e){t={error:e}}finally{try{n&&!n.done&&(e=r.return)&&e.call(r)}finally{if(t)throw t.error}}return null},e.prototype.lastNonSpace=function(){for(var t=this.childNodes.length;--t>=0;){var e=this.childNodes[t];if(e&&!e.isSpacelike)return e}return null},e.prototype.setTeXclass=function(t){var e,r,n,o;if(null!=this.getProperty("open")||null!=this.getProperty("close")){this.getPrevClass(t),t=null;try{for(var i=s(this.childNodes),l=i.next();!l.done;l=i.next()){t=l.value.setTeXclass(t)}}catch(t){e={error:t}}finally{try{l&&!l.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}null==this.texClass&&(this.texClass=a.TEXCLASS.INNER)}else{try{for(var c=s(this.childNodes),u=c.next();!u.done;u=c.next()){t=u.value.setTeXclass(t)}}catch(t){n={error:t}}finally{try{u&&!u.done&&(o=c.return)&&o.call(c)}finally{if(n)throw n.error}}this.childNodes[0]&&this.updateTeXclass(this.childNodes[0])}return t},e.defaults=i({},a.AbstractMmlNode.defaults),e}(a.AbstractMmlNode);e.MmlMrow=l;var c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"inferredMrow"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isInferred",{get:function(){return!0},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"notParent",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.toString=function(){return"["+this.childNodes.join(",")+"]"},e.defaults=l.defaults,e}(l);e.MmlInferredMrow=c},7265:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlMtable=void 0;var a=r(9007),l=r(505),c=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.properties={useHeight:!0},e.texclass=a.TEXCLASS.ORD,e}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mtable"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"linebreakContainer",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.setInheritedAttributes=function(e,r,n,o){var i,l;try{for(var c=s(a.indentAttributes),u=c.next();!u.done;u=c.next()){var p=u.value;e[p]&&this.attributes.setInherited(p,e[p][1]),void 0!==this.attributes.getExplicit(p)&&delete this.attributes.getAllAttributes()[p]}}catch(t){i={error:t}}finally{try{u&&!u.done&&(l=c.return)&&l.call(c)}finally{if(i)throw i.error}}t.prototype.setInheritedAttributes.call(this,e,r,n,o)},e.prototype.setChildInheritedAttributes=function(t,e,r,n){var o,i,a,c;try{for(var u=s(this.childNodes),p=u.next();!p.done;p=u.next()){(y=p.value).isKind("mtr")||this.replaceChild(this.factory.create("mtr"),y).appendChild(y)}}catch(t){o={error:t}}finally{try{p&&!p.done&&(i=u.return)&&i.call(u)}finally{if(o)throw o.error}}r=this.getProperty("scriptlevel")||r,e=!(!this.attributes.getExplicit("displaystyle")&&!this.attributes.getDefault("displaystyle")),t=this.addInheritedAttributes(t,{columnalign:this.attributes.get("columnalign"),rowalign:"center"});var h=this.attributes.getExplicit("data-cramped"),f=(0,l.split)(this.attributes.get("rowalign"));try{for(var d=s(this.childNodes),m=d.next();!m.done;m=d.next()){var y=m.value;t.rowalign[1]=f.shift()||t.rowalign[1],y.setInheritedAttributes(t,e,r,!!h)}}catch(t){a={error:t}}finally{try{m&&!m.done&&(c=d.return)&&c.call(d)}finally{if(a)throw a.error}}},e.prototype.verifyChildren=function(e){for(var r=null,n=this.factory,o=0;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MmlMlabeledtr=e.MmlMtr=void 0;var a=r(9007),l=r(91),c=r(505),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mtr"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"linebreakContainer",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.setChildInheritedAttributes=function(t,e,r,n){var o,i,a,l;try{for(var u=s(this.childNodes),p=u.next();!p.done;p=u.next()){(m=p.value).isKind("mtd")||this.replaceChild(this.factory.create("mtd"),m).appendChild(m)}}catch(t){o={error:t}}finally{try{p&&!p.done&&(i=u.return)&&i.call(u)}finally{if(o)throw o.error}}var h=(0,c.split)(this.attributes.get("columnalign"));1===this.arity&&h.unshift(this.parent.attributes.get("side")),t=this.addInheritedAttributes(t,{rowalign:this.attributes.get("rowalign"),columnalign:"center"});try{for(var f=s(this.childNodes),d=f.next();!d.done;d=f.next()){var m=d.value;t.columnalign[1]=h.shift()||t.columnalign[1],m.setInheritedAttributes(t,e,r,n)}}catch(t){a={error:t}}finally{try{d&&!d.done&&(l=f.return)&&l.call(f)}finally{if(a)throw a.error}}},e.prototype.verifyChildren=function(e){var r,n;if(!this.parent||this.parent.isKind("mtable")){try{for(var o=s(this.childNodes),i=o.next();!i.done;i=o.next()){var a=i.value;if(!a.isKind("mtd"))this.replaceChild(this.factory.create("mtd"),a).appendChild(a),e.fixMtables||a.mError("Children of "+this.kind+" must be mtd",e)}}catch(t){r={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}t.prototype.verifyChildren.call(this,e)}else this.mError(this.kind+" can only be a child of an mtable",e,!0)},e.prototype.setTeXclass=function(t){var e,r;this.getPrevClass(t);try{for(var n=s(this.childNodes),o=n.next();!o.done;o=n.next()){o.value.setTeXclass(null)}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}return this},e.defaults=i(i({},a.AbstractMmlNode.defaults),{rowalign:l.INHERIT,columnalign:l.INHERIT,groupalign:l.INHERIT}),e}(a.AbstractMmlNode);e.MmlMtr=u;var p=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"mlabeledtr"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"arity",{get:function(){return 1},enumerable:!1,configurable:!0}),e}(u);e.MmlMlabeledtr=p},5184:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.OPTABLE=e.MMLSPACING=e.getRange=e.RANGES=e.MO=e.OPDEF=void 0;var o=r(9007);function i(t,e,r,n){return void 0===r&&(r=o.TEXCLASS.BIN),void 0===n&&(n=null),[t,e,r,n]}e.OPDEF=i,e.MO={ORD:i(0,0,o.TEXCLASS.ORD),ORD11:i(1,1,o.TEXCLASS.ORD),ORD21:i(2,1,o.TEXCLASS.ORD),ORD02:i(0,2,o.TEXCLASS.ORD),ORD55:i(5,5,o.TEXCLASS.ORD),NONE:i(0,0,o.TEXCLASS.NONE),OP:i(1,2,o.TEXCLASS.OP,{largeop:!0,movablelimits:!0,symmetric:!0}),OPFIXED:i(1,2,o.TEXCLASS.OP,{largeop:!0,movablelimits:!0}),INTEGRAL:i(0,1,o.TEXCLASS.OP,{largeop:!0,symmetric:!0}),INTEGRAL2:i(1,2,o.TEXCLASS.OP,{largeop:!0,symmetric:!0}),BIN3:i(3,3,o.TEXCLASS.BIN),BIN4:i(4,4,o.TEXCLASS.BIN),BIN01:i(0,1,o.TEXCLASS.BIN),BIN5:i(5,5,o.TEXCLASS.BIN),TALLBIN:i(4,4,o.TEXCLASS.BIN,{stretchy:!0}),BINOP:i(4,4,o.TEXCLASS.BIN,{largeop:!0,movablelimits:!0}),REL:i(5,5,o.TEXCLASS.REL),REL1:i(1,1,o.TEXCLASS.REL,{stretchy:!0}),REL4:i(4,4,o.TEXCLASS.REL),RELSTRETCH:i(5,5,o.TEXCLASS.REL,{stretchy:!0}),RELACCENT:i(5,5,o.TEXCLASS.REL,{accent:!0}),WIDEREL:i(5,5,o.TEXCLASS.REL,{accent:!0,stretchy:!0}),OPEN:i(0,0,o.TEXCLASS.OPEN,{fence:!0,stretchy:!0,symmetric:!0}),CLOSE:i(0,0,o.TEXCLASS.CLOSE,{fence:!0,stretchy:!0,symmetric:!0}),INNER:i(0,0,o.TEXCLASS.INNER),PUNCT:i(0,3,o.TEXCLASS.PUNCT),ACCENT:i(0,0,o.TEXCLASS.ORD,{accent:!0}),WIDEACCENT:i(0,0,o.TEXCLASS.ORD,{accent:!0,stretchy:!0})},e.RANGES=[[32,127,o.TEXCLASS.REL,"mo"],[160,191,o.TEXCLASS.ORD,"mo"],[192,591,o.TEXCLASS.ORD,"mi"],[688,879,o.TEXCLASS.ORD,"mo"],[880,6688,o.TEXCLASS.ORD,"mi"],[6832,6911,o.TEXCLASS.ORD,"mo"],[6912,7615,o.TEXCLASS.ORD,"mi"],[7616,7679,o.TEXCLASS.ORD,"mo"],[7680,8191,o.TEXCLASS.ORD,"mi"],[8192,8303,o.TEXCLASS.ORD,"mo"],[8304,8351,o.TEXCLASS.ORD,"mo"],[8448,8527,o.TEXCLASS.ORD,"mi"],[8528,8591,o.TEXCLASS.ORD,"mn"],[8592,8703,o.TEXCLASS.REL,"mo"],[8704,8959,o.TEXCLASS.BIN,"mo"],[8960,9215,o.TEXCLASS.ORD,"mo"],[9312,9471,o.TEXCLASS.ORD,"mn"],[9472,10223,o.TEXCLASS.ORD,"mo"],[10224,10239,o.TEXCLASS.REL,"mo"],[10240,10495,o.TEXCLASS.ORD,"mtext"],[10496,10623,o.TEXCLASS.REL,"mo"],[10624,10751,o.TEXCLASS.ORD,"mo"],[10752,11007,o.TEXCLASS.BIN,"mo"],[11008,11055,o.TEXCLASS.ORD,"mo"],[11056,11087,o.TEXCLASS.REL,"mo"],[11088,11263,o.TEXCLASS.ORD,"mo"],[11264,11744,o.TEXCLASS.ORD,"mi"],[11776,11903,o.TEXCLASS.ORD,"mo"],[11904,12255,o.TEXCLASS.ORD,"mi","normal"],[12272,12351,o.TEXCLASS.ORD,"mo"],[12352,42143,o.TEXCLASS.ORD,"mi","normal"],[42192,43055,o.TEXCLASS.ORD,"mi"],[43056,43071,o.TEXCLASS.ORD,"mn"],[43072,55295,o.TEXCLASS.ORD,"mi"],[63744,64255,o.TEXCLASS.ORD,"mi","normal"],[64256,65023,o.TEXCLASS.ORD,"mi"],[65024,65135,o.TEXCLASS.ORD,"mo"],[65136,65791,o.TEXCLASS.ORD,"mi"],[65792,65935,o.TEXCLASS.ORD,"mn"],[65936,74751,o.TEXCLASS.ORD,"mi","normal"],[74752,74879,o.TEXCLASS.ORD,"mn"],[74880,113823,o.TEXCLASS.ORD,"mi","normal"],[113824,119391,o.TEXCLASS.ORD,"mo"],[119648,119679,o.TEXCLASS.ORD,"mn"],[119808,120781,o.TEXCLASS.ORD,"mi"],[120782,120831,o.TEXCLASS.ORD,"mn"],[122624,129023,o.TEXCLASS.ORD,"mo"],[129024,129279,o.TEXCLASS.REL,"mo"],[129280,129535,o.TEXCLASS.ORD,"mo"],[131072,195103,o.TEXCLASS.ORD,"mi","normnal"]],e.getRange=function(t){var r,o,i=t.codePointAt(0);try{for(var s=n(e.RANGES),a=s.next();!a.done;a=s.next()){var l=a.value;if(i<=l[1]){if(i>=l[0])return l;break}}}catch(t){r={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(r)throw r.error}}return null},e.MMLSPACING=[[0,0],[1,2],[3,3],[4,4],[0,0],[0,0],[0,3]],e.OPTABLE={prefix:{"(":e.MO.OPEN,"+":e.MO.BIN01,"-":e.MO.BIN01,"[":e.MO.OPEN,"{":e.MO.OPEN,"|":e.MO.OPEN,"||":[0,0,o.TEXCLASS.BIN,{fence:!0,stretchy:!0,symmetric:!0}],"|||":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0,symmetric:!0}],"\xac":e.MO.ORD21,"\xb1":e.MO.BIN01,"\u2016":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0}],"\u2018":[0,0,o.TEXCLASS.OPEN,{fence:!0}],"\u201c":[0,0,o.TEXCLASS.OPEN,{fence:!0}],"\u2145":e.MO.ORD21,"\u2146":i(2,0,o.TEXCLASS.ORD),"\u2200":e.MO.ORD21,"\u2202":e.MO.ORD21,"\u2203":e.MO.ORD21,"\u2204":e.MO.ORD21,"\u2207":e.MO.ORD21,"\u220f":e.MO.OP,"\u2210":e.MO.OP,"\u2211":e.MO.OP,"\u2212":e.MO.BIN01,"\u2213":e.MO.BIN01,"\u221a":[1,1,o.TEXCLASS.ORD,{stretchy:!0}],"\u221b":e.MO.ORD11,"\u221c":e.MO.ORD11,"\u2220":e.MO.ORD,"\u2221":e.MO.ORD,"\u2222":e.MO.ORD,"\u222b":e.MO.INTEGRAL,"\u222c":e.MO.INTEGRAL,"\u222d":e.MO.INTEGRAL,"\u222e":e.MO.INTEGRAL,"\u222f":e.MO.INTEGRAL,"\u2230":e.MO.INTEGRAL,"\u2231":e.MO.INTEGRAL,"\u2232":e.MO.INTEGRAL,"\u2233":e.MO.INTEGRAL,"\u22c0":e.MO.OP,"\u22c1":e.MO.OP,"\u22c2":e.MO.OP,"\u22c3":e.MO.OP,"\u2308":e.MO.OPEN,"\u230a":e.MO.OPEN,"\u2329":e.MO.OPEN,"\u2772":e.MO.OPEN,"\u27e6":e.MO.OPEN,"\u27e8":e.MO.OPEN,"\u27ea":e.MO.OPEN,"\u27ec":e.MO.OPEN,"\u27ee":e.MO.OPEN,"\u2980":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0}],"\u2983":e.MO.OPEN,"\u2985":e.MO.OPEN,"\u2987":e.MO.OPEN,"\u2989":e.MO.OPEN,"\u298b":e.MO.OPEN,"\u298d":e.MO.OPEN,"\u298f":e.MO.OPEN,"\u2991":e.MO.OPEN,"\u2993":e.MO.OPEN,"\u2995":e.MO.OPEN,"\u2997":e.MO.OPEN,"\u29fc":e.MO.OPEN,"\u2a00":e.MO.OP,"\u2a01":e.MO.OP,"\u2a02":e.MO.OP,"\u2a03":e.MO.OP,"\u2a04":e.MO.OP,"\u2a05":e.MO.OP,"\u2a06":e.MO.OP,"\u2a07":e.MO.OP,"\u2a08":e.MO.OP,"\u2a09":e.MO.OP,"\u2a0a":e.MO.OP,"\u2a0b":e.MO.INTEGRAL2,"\u2a0c":e.MO.INTEGRAL,"\u2a0d":e.MO.INTEGRAL2,"\u2a0e":e.MO.INTEGRAL2,"\u2a0f":e.MO.INTEGRAL2,"\u2a10":e.MO.OP,"\u2a11":e.MO.OP,"\u2a12":e.MO.OP,"\u2a13":e.MO.OP,"\u2a14":e.MO.OP,"\u2a15":e.MO.INTEGRAL2,"\u2a16":e.MO.INTEGRAL2,"\u2a17":e.MO.INTEGRAL2,"\u2a18":e.MO.INTEGRAL2,"\u2a19":e.MO.INTEGRAL2,"\u2a1a":e.MO.INTEGRAL2,"\u2a1b":e.MO.INTEGRAL2,"\u2a1c":e.MO.INTEGRAL2,"\u2afc":e.MO.OP,"\u2aff":e.MO.OP},postfix:{"!!":i(1,0),"!":[1,0,o.TEXCLASS.CLOSE,null],'"':e.MO.ACCENT,"&":e.MO.ORD,")":e.MO.CLOSE,"++":i(0,0),"--":i(0,0),"..":i(0,0),"...":e.MO.ORD,"'":e.MO.ACCENT,"]":e.MO.CLOSE,"^":e.MO.WIDEACCENT,_:e.MO.WIDEACCENT,"`":e.MO.ACCENT,"|":e.MO.CLOSE,"}":e.MO.CLOSE,"~":e.MO.WIDEACCENT,"||":[0,0,o.TEXCLASS.BIN,{fence:!0,stretchy:!0,symmetric:!0}],"|||":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0,symmetric:!0}],"\xa8":e.MO.ACCENT,"\xaa":e.MO.ACCENT,"\xaf":e.MO.WIDEACCENT,"\xb0":e.MO.ORD,"\xb2":e.MO.ACCENT,"\xb3":e.MO.ACCENT,"\xb4":e.MO.ACCENT,"\xb8":e.MO.ACCENT,"\xb9":e.MO.ACCENT,"\xba":e.MO.ACCENT,"\u02c6":e.MO.WIDEACCENT,"\u02c7":e.MO.WIDEACCENT,"\u02c9":e.MO.WIDEACCENT,"\u02ca":e.MO.ACCENT,"\u02cb":e.MO.ACCENT,"\u02cd":e.MO.WIDEACCENT,"\u02d8":e.MO.ACCENT,"\u02d9":e.MO.ACCENT,"\u02da":e.MO.ACCENT,"\u02dc":e.MO.WIDEACCENT,"\u02dd":e.MO.ACCENT,"\u02f7":e.MO.WIDEACCENT,"\u0302":e.MO.WIDEACCENT,"\u0311":e.MO.ACCENT,"\u03f6":e.MO.REL,"\u2016":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0}],"\u2019":[0,0,o.TEXCLASS.CLOSE,{fence:!0}],"\u201a":e.MO.ACCENT,"\u201b":e.MO.ACCENT,"\u201d":[0,0,o.TEXCLASS.CLOSE,{fence:!0}],"\u201e":e.MO.ACCENT,"\u201f":e.MO.ACCENT,"\u2032":e.MO.ORD,"\u2033":e.MO.ACCENT,"\u2034":e.MO.ACCENT,"\u2035":e.MO.ACCENT,"\u2036":e.MO.ACCENT,"\u2037":e.MO.ACCENT,"\u203e":e.MO.WIDEACCENT,"\u2057":e.MO.ACCENT,"\u20db":e.MO.ACCENT,"\u20dc":e.MO.ACCENT,"\u2309":e.MO.CLOSE,"\u230b":e.MO.CLOSE,"\u232a":e.MO.CLOSE,"\u23b4":e.MO.WIDEACCENT,"\u23b5":e.MO.WIDEACCENT,"\u23dc":e.MO.WIDEACCENT,"\u23dd":e.MO.WIDEACCENT,"\u23de":e.MO.WIDEACCENT,"\u23df":e.MO.WIDEACCENT,"\u23e0":e.MO.WIDEACCENT,"\u23e1":e.MO.WIDEACCENT,"\u25a0":e.MO.BIN3,"\u25a1":e.MO.BIN3,"\u25aa":e.MO.BIN3,"\u25ab":e.MO.BIN3,"\u25ad":e.MO.BIN3,"\u25ae":e.MO.BIN3,"\u25af":e.MO.BIN3,"\u25b0":e.MO.BIN3,"\u25b1":e.MO.BIN3,"\u25b2":e.MO.BIN4,"\u25b4":e.MO.BIN4,"\u25b6":e.MO.BIN4,"\u25b7":e.MO.BIN4,"\u25b8":e.MO.BIN4,"\u25bc":e.MO.BIN4,"\u25be":e.MO.BIN4,"\u25c0":e.MO.BIN4,"\u25c1":e.MO.BIN4,"\u25c2":e.MO.BIN4,"\u25c4":e.MO.BIN4,"\u25c5":e.MO.BIN4,"\u25c6":e.MO.BIN4,"\u25c7":e.MO.BIN4,"\u25c8":e.MO.BIN4,"\u25c9":e.MO.BIN4,"\u25cc":e.MO.BIN4,"\u25cd":e.MO.BIN4,"\u25ce":e.MO.BIN4,"\u25cf":e.MO.BIN4,"\u25d6":e.MO.BIN4,"\u25d7":e.MO.BIN4,"\u25e6":e.MO.BIN4,"\u266d":e.MO.ORD02,"\u266e":e.MO.ORD02,"\u266f":e.MO.ORD02,"\u2773":e.MO.CLOSE,"\u27e7":e.MO.CLOSE,"\u27e9":e.MO.CLOSE,"\u27eb":e.MO.CLOSE,"\u27ed":e.MO.CLOSE,"\u27ef":e.MO.CLOSE,"\u2980":[0,0,o.TEXCLASS.ORD,{fence:!0,stretchy:!0}],"\u2984":e.MO.CLOSE,"\u2986":e.MO.CLOSE,"\u2988":e.MO.CLOSE,"\u298a":e.MO.CLOSE,"\u298c":e.MO.CLOSE,"\u298e":e.MO.CLOSE,"\u2990":e.MO.CLOSE,"\u2992":e.MO.CLOSE,"\u2994":e.MO.CLOSE,"\u2996":e.MO.CLOSE,"\u2998":e.MO.CLOSE,"\u29fd":e.MO.CLOSE},infix:{"!=":e.MO.BIN4,"#":e.MO.ORD,$:e.MO.ORD,"%":[3,3,o.TEXCLASS.ORD,null],"&&":e.MO.BIN4,"":e.MO.ORD,"*":e.MO.BIN3,"**":i(1,1),"*=":e.MO.BIN4,"+":e.MO.BIN4,"+=":e.MO.BIN4,",":[0,3,o.TEXCLASS.PUNCT,{linebreakstyle:"after",separator:!0}],"-":e.MO.BIN4,"-=":e.MO.BIN4,"->":e.MO.BIN5,".":[0,3,o.TEXCLASS.PUNCT,{separator:!0}],"/":e.MO.ORD11,"//":i(1,1),"/=":e.MO.BIN4,":":[1,2,o.TEXCLASS.REL,null],":=":e.MO.BIN4,";":[0,3,o.TEXCLASS.PUNCT,{linebreakstyle:"after",separator:!0}],"<":e.MO.REL,"<=":e.MO.BIN5,"<>":i(1,1),"=":e.MO.REL,"==":e.MO.BIN4,">":e.MO.REL,">=":e.MO.BIN5,"?":[1,1,o.TEXCLASS.CLOSE,null],"@":e.MO.ORD11,"\\":e.MO.ORD,"^":e.MO.ORD11,_:e.MO.ORD11,"|":[2,2,o.TEXCLASS.ORD,{fence:!0,stretchy:!0,symmetric:!0}],"||":[2,2,o.TEXCLASS.BIN,{fence:!0,stretchy:!0,symmetric:!0}],"|||":[2,2,o.TEXCLASS.ORD,{fence:!0,stretchy:!0,symmetric:!0}],"\xb1":e.MO.BIN4,"\xb7":e.MO.BIN4,"\xd7":e.MO.BIN4,"\xf7":e.MO.BIN4,"\u02b9":e.MO.ORD,"\u0300":e.MO.ACCENT,"\u0301":e.MO.ACCENT,"\u0303":e.MO.WIDEACCENT,"\u0304":e.MO.ACCENT,"\u0306":e.MO.ACCENT,"\u0307":e.MO.ACCENT,"\u0308":e.MO.ACCENT,"\u030c":e.MO.ACCENT,"\u0332":e.MO.WIDEACCENT,"\u0338":e.MO.REL4,"\u2015":[0,0,o.TEXCLASS.ORD,{stretchy:!0}],"\u2017":[0,0,o.TEXCLASS.ORD,{stretchy:!0}],"\u2020":e.MO.BIN3,"\u2021":e.MO.BIN3,"\u2022":e.MO.BIN4,"\u2026":e.MO.INNER,"\u2043":e.MO.BIN4,"\u2044":e.MO.TALLBIN,"\u2061":e.MO.NONE,"\u2062":e.MO.NONE,"\u2063":[0,0,o.TEXCLASS.NONE,{linebreakstyle:"after",separator:!0}],"\u2064":e.MO.NONE,"\u20d7":e.MO.ACCENT,"\u2111":e.MO.ORD,"\u2113":e.MO.ORD,"\u2118":e.MO.ORD,"\u211c":e.MO.ORD,"\u2190":e.MO.WIDEREL,"\u2191":e.MO.RELSTRETCH,"\u2192":e.MO.WIDEREL,"\u2193":e.MO.RELSTRETCH,"\u2194":e.MO.WIDEREL,"\u2195":e.MO.RELSTRETCH,"\u2196":e.MO.RELSTRETCH,"\u2197":e.MO.RELSTRETCH,"\u2198":e.MO.RELSTRETCH,"\u2199":e.MO.RELSTRETCH,"\u219a":e.MO.RELACCENT,"\u219b":e.MO.RELACCENT,"\u219c":e.MO.WIDEREL,"\u219d":e.MO.WIDEREL,"\u219e":e.MO.WIDEREL,"\u219f":e.MO.WIDEREL,"\u21a0":e.MO.WIDEREL,"\u21a1":e.MO.RELSTRETCH,"\u21a2":e.MO.WIDEREL,"\u21a3":e.MO.WIDEREL,"\u21a4":e.MO.WIDEREL,"\u21a5":e.MO.RELSTRETCH,"\u21a6":e.MO.WIDEREL,"\u21a7":e.MO.RELSTRETCH,"\u21a8":e.MO.RELSTRETCH,"\u21a9":e.MO.WIDEREL,"\u21aa":e.MO.WIDEREL,"\u21ab":e.MO.WIDEREL,"\u21ac":e.MO.WIDEREL,"\u21ad":e.MO.WIDEREL,"\u21ae":e.MO.RELACCENT,"\u21af":e.MO.RELSTRETCH,"\u21b0":e.MO.RELSTRETCH,"\u21b1":e.MO.RELSTRETCH,"\u21b2":e.MO.RELSTRETCH,"\u21b3":e.MO.RELSTRETCH,"\u21b4":e.MO.RELSTRETCH,"\u21b5":e.MO.RELSTRETCH,"\u21b6":e.MO.RELACCENT,"\u21b7":e.MO.RELACCENT,"\u21b8":e.MO.REL,"\u21b9":e.MO.WIDEREL,"\u21ba":e.MO.REL,"\u21bb":e.MO.REL,"\u21bc":e.MO.WIDEREL,"\u21bd":e.MO.WIDEREL,"\u21be":e.MO.RELSTRETCH,"\u21bf":e.MO.RELSTRETCH,"\u21c0":e.MO.WIDEREL,"\u21c1":e.MO.WIDEREL,"\u21c2":e.MO.RELSTRETCH,"\u21c3":e.MO.RELSTRETCH,"\u21c4":e.MO.WIDEREL,"\u21c5":e.MO.RELSTRETCH,"\u21c6":e.MO.WIDEREL,"\u21c7":e.MO.WIDEREL,"\u21c8":e.MO.RELSTRETCH,"\u21c9":e.MO.WIDEREL,"\u21ca":e.MO.RELSTRETCH,"\u21cb":e.MO.WIDEREL,"\u21cc":e.MO.WIDEREL,"\u21cd":e.MO.RELACCENT,"\u21ce":e.MO.RELACCENT,"\u21cf":e.MO.RELACCENT,"\u21d0":e.MO.WIDEREL,"\u21d1":e.MO.RELSTRETCH,"\u21d2":e.MO.WIDEREL,"\u21d3":e.MO.RELSTRETCH,"\u21d4":e.MO.WIDEREL,"\u21d5":e.MO.RELSTRETCH,"\u21d6":e.MO.RELSTRETCH,"\u21d7":e.MO.RELSTRETCH,"\u21d8":e.MO.RELSTRETCH,"\u21d9":e.MO.RELSTRETCH,"\u21da":e.MO.WIDEREL,"\u21db":e.MO.WIDEREL,"\u21dc":e.MO.WIDEREL,"\u21dd":e.MO.WIDEREL,"\u21de":e.MO.REL,"\u21df":e.MO.REL,"\u21e0":e.MO.WIDEREL,"\u21e1":e.MO.RELSTRETCH,"\u21e2":e.MO.WIDEREL,"\u21e3":e.MO.RELSTRETCH,"\u21e4":e.MO.WIDEREL,"\u21e5":e.MO.WIDEREL,"\u21e6":e.MO.WIDEREL,"\u21e7":e.MO.RELSTRETCH,"\u21e8":e.MO.WIDEREL,"\u21e9":e.MO.RELSTRETCH,"\u21ea":e.MO.RELSTRETCH,"\u21eb":e.MO.RELSTRETCH,"\u21ec":e.MO.RELSTRETCH,"\u21ed":e.MO.RELSTRETCH,"\u21ee":e.MO.RELSTRETCH,"\u21ef":e.MO.RELSTRETCH,"\u21f0":e.MO.WIDEREL,"\u21f1":e.MO.REL,"\u21f2":e.MO.REL,"\u21f3":e.MO.RELSTRETCH,"\u21f4":e.MO.RELACCENT,"\u21f5":e.MO.RELSTRETCH,"\u21f6":e.MO.WIDEREL,"\u21f7":e.MO.RELACCENT,"\u21f8":e.MO.RELACCENT,"\u21f9":e.MO.RELACCENT,"\u21fa":e.MO.RELACCENT,"\u21fb":e.MO.RELACCENT,"\u21fc":e.MO.RELACCENT,"\u21fd":e.MO.WIDEREL,"\u21fe":e.MO.WIDEREL,"\u21ff":e.MO.WIDEREL,"\u2201":i(1,2,o.TEXCLASS.ORD),"\u2205":e.MO.ORD,"\u2206":e.MO.BIN3,"\u2208":e.MO.REL,"\u2209":e.MO.REL,"\u220a":e.MO.REL,"\u220b":e.MO.REL,"\u220c":e.MO.REL,"\u220d":e.MO.REL,"\u220e":e.MO.BIN3,"\u2212":e.MO.BIN4,"\u2213":e.MO.BIN4,"\u2214":e.MO.BIN4,"\u2215":e.MO.TALLBIN,"\u2216":e.MO.BIN4,"\u2217":e.MO.BIN4,"\u2218":e.MO.BIN4,"\u2219":e.MO.BIN4,"\u221d":e.MO.REL,"\u221e":e.MO.ORD,"\u221f":e.MO.REL,"\u2223":e.MO.REL,"\u2224":e.MO.REL,"\u2225":e.MO.REL,"\u2226":e.MO.REL,"\u2227":e.MO.BIN4,"\u2228":e.MO.BIN4,"\u2229":e.MO.BIN4,"\u222a":e.MO.BIN4,"\u2234":e.MO.REL,"\u2235":e.MO.REL,"\u2236":e.MO.REL,"\u2237":e.MO.REL,"\u2238":e.MO.BIN4,"\u2239":e.MO.REL,"\u223a":e.MO.BIN4,"\u223b":e.MO.REL,"\u223c":e.MO.REL,"\u223d":e.MO.REL,"\u223d\u0331":e.MO.BIN3,"\u223e":e.MO.REL,"\u223f":e.MO.BIN3,"\u2240":e.MO.BIN4,"\u2241":e.MO.REL,"\u2242":e.MO.REL,"\u2242\u0338":e.MO.REL,"\u2243":e.MO.REL,"\u2244":e.MO.REL,"\u2245":e.MO.REL,"\u2246":e.MO.REL,"\u2247":e.MO.REL,"\u2248":e.MO.REL,"\u2249":e.MO.REL,"\u224a":e.MO.REL,"\u224b":e.MO.REL,"\u224c":e.MO.REL,"\u224d":e.MO.REL,"\u224e":e.MO.REL,"\u224e\u0338":e.MO.REL,"\u224f":e.MO.REL,"\u224f\u0338":e.MO.REL,"\u2250":e.MO.REL,"\u2251":e.MO.REL,"\u2252":e.MO.REL,"\u2253":e.MO.REL,"\u2254":e.MO.REL,"\u2255":e.MO.REL,"\u2256":e.MO.REL,"\u2257":e.MO.REL,"\u2258":e.MO.REL,"\u2259":e.MO.REL,"\u225a":e.MO.REL,"\u225b":e.MO.REL,"\u225c":e.MO.REL,"\u225d":e.MO.REL,"\u225e":e.MO.REL,"\u225f":e.MO.REL,"\u2260":e.MO.REL,"\u2261":e.MO.REL,"\u2262":e.MO.REL,"\u2263":e.MO.REL,"\u2264":e.MO.REL,"\u2265":e.MO.REL,"\u2266":e.MO.REL,"\u2266\u0338":e.MO.REL,"\u2267":e.MO.REL,"\u2268":e.MO.REL,"\u2269":e.MO.REL,"\u226a":e.MO.REL,"\u226a\u0338":e.MO.REL,"\u226b":e.MO.REL,"\u226b\u0338":e.MO.REL,"\u226c":e.MO.REL,"\u226d":e.MO.REL,"\u226e":e.MO.REL,"\u226f":e.MO.REL,"\u2270":e.MO.REL,"\u2271":e.MO.REL,"\u2272":e.MO.REL,"\u2273":e.MO.REL,"\u2274":e.MO.REL,"\u2275":e.MO.REL,"\u2276":e.MO.REL,"\u2277":e.MO.REL,"\u2278":e.MO.REL,"\u2279":e.MO.REL,"\u227a":e.MO.REL,"\u227b":e.MO.REL,"\u227c":e.MO.REL,"\u227d":e.MO.REL,"\u227e":e.MO.REL,"\u227f":e.MO.REL,"\u227f\u0338":e.MO.REL,"\u2280":e.MO.REL,"\u2281":e.MO.REL,"\u2282":e.MO.REL,"\u2282\u20d2":e.MO.REL,"\u2283":e.MO.REL,"\u2283\u20d2":e.MO.REL,"\u2284":e.MO.REL,"\u2285":e.MO.REL,"\u2286":e.MO.REL,"\u2287":e.MO.REL,"\u2288":e.MO.REL,"\u2289":e.MO.REL,"\u228a":e.MO.REL,"\u228b":e.MO.REL,"\u228c":e.MO.BIN4,"\u228d":e.MO.BIN4,"\u228e":e.MO.BIN4,"\u228f":e.MO.REL,"\u228f\u0338":e.MO.REL,"\u2290":e.MO.REL,"\u2290\u0338":e.MO.REL,"\u2291":e.MO.REL,"\u2292":e.MO.REL,"\u2293":e.MO.BIN4,"\u2294":e.MO.BIN4,"\u2295":e.MO.BIN4,"\u2296":e.MO.BIN4,"\u2297":e.MO.BIN4,"\u2298":e.MO.BIN4,"\u2299":e.MO.BIN4,"\u229a":e.MO.BIN4,"\u229b":e.MO.BIN4,"\u229c":e.MO.BIN4,"\u229d":e.MO.BIN4,"\u229e":e.MO.BIN4,"\u229f":e.MO.BIN4,"\u22a0":e.MO.BIN4,"\u22a1":e.MO.BIN4,"\u22a2":e.MO.REL,"\u22a3":e.MO.REL,"\u22a4":e.MO.ORD55,"\u22a5":e.MO.REL,"\u22a6":e.MO.REL,"\u22a7":e.MO.REL,"\u22a8":e.MO.REL,"\u22a9":e.MO.REL,"\u22aa":e.MO.REL,"\u22ab":e.MO.REL,"\u22ac":e.MO.REL,"\u22ad":e.MO.REL,"\u22ae":e.MO.REL,"\u22af":e.MO.REL,"\u22b0":e.MO.REL,"\u22b1":e.MO.REL,"\u22b2":e.MO.REL,"\u22b3":e.MO.REL,"\u22b4":e.MO.REL,"\u22b5":e.MO.REL,"\u22b6":e.MO.REL,"\u22b7":e.MO.REL,"\u22b8":e.MO.REL,"\u22b9":e.MO.REL,"\u22ba":e.MO.BIN4,"\u22bb":e.MO.BIN4,"\u22bc":e.MO.BIN4,"\u22bd":e.MO.BIN4,"\u22be":e.MO.BIN3,"\u22bf":e.MO.BIN3,"\u22c4":e.MO.BIN4,"\u22c5":e.MO.BIN4,"\u22c6":e.MO.BIN4,"\u22c7":e.MO.BIN4,"\u22c8":e.MO.REL,"\u22c9":e.MO.BIN4,"\u22ca":e.MO.BIN4,"\u22cb":e.MO.BIN4,"\u22cc":e.MO.BIN4,"\u22cd":e.MO.REL,"\u22ce":e.MO.BIN4,"\u22cf":e.MO.BIN4,"\u22d0":e.MO.REL,"\u22d1":e.MO.REL,"\u22d2":e.MO.BIN4,"\u22d3":e.MO.BIN4,"\u22d4":e.MO.REL,"\u22d5":e.MO.REL,"\u22d6":e.MO.REL,"\u22d7":e.MO.REL,"\u22d8":e.MO.REL,"\u22d9":e.MO.REL,"\u22da":e.MO.REL,"\u22db":e.MO.REL,"\u22dc":e.MO.REL,"\u22dd":e.MO.REL,"\u22de":e.MO.REL,"\u22df":e.MO.REL,"\u22e0":e.MO.REL,"\u22e1":e.MO.REL,"\u22e2":e.MO.REL,"\u22e3":e.MO.REL,"\u22e4":e.MO.REL,"\u22e5":e.MO.REL,"\u22e6":e.MO.REL,"\u22e7":e.MO.REL,"\u22e8":e.MO.REL,"\u22e9":e.MO.REL,"\u22ea":e.MO.REL,"\u22eb":e.MO.REL,"\u22ec":e.MO.REL,"\u22ed":e.MO.REL,"\u22ee":e.MO.ORD55,"\u22ef":e.MO.INNER,"\u22f0":e.MO.REL,"\u22f1":[5,5,o.TEXCLASS.INNER,null],"\u22f2":e.MO.REL,"\u22f3":e.MO.REL,"\u22f4":e.MO.REL,"\u22f5":e.MO.REL,"\u22f6":e.MO.REL,"\u22f7":e.MO.REL,"\u22f8":e.MO.REL,"\u22f9":e.MO.REL,"\u22fa":e.MO.REL,"\u22fb":e.MO.REL,"\u22fc":e.MO.REL,"\u22fd":e.MO.REL,"\u22fe":e.MO.REL,"\u22ff":e.MO.REL,"\u2305":e.MO.BIN3,"\u2306":e.MO.BIN3,"\u2322":e.MO.REL4,"\u2323":e.MO.REL4,"\u2329":e.MO.OPEN,"\u232a":e.MO.CLOSE,"\u23aa":e.MO.ORD,"\u23af":[0,0,o.TEXCLASS.ORD,{stretchy:!0}],"\u23b0":e.MO.OPEN,"\u23b1":e.MO.CLOSE,"\u2500":e.MO.ORD,"\u25b3":e.MO.BIN4,"\u25b5":e.MO.BIN4,"\u25b9":e.MO.BIN4,"\u25bd":e.MO.BIN4,"\u25bf":e.MO.BIN4,"\u25c3":e.MO.BIN4,"\u25ef":e.MO.BIN3,"\u2660":e.MO.ORD,"\u2661":e.MO.ORD,"\u2662":e.MO.ORD,"\u2663":e.MO.ORD,"\u2758":e.MO.REL,"\u27f0":e.MO.RELSTRETCH,"\u27f1":e.MO.RELSTRETCH,"\u27f5":e.MO.WIDEREL,"\u27f6":e.MO.WIDEREL,"\u27f7":e.MO.WIDEREL,"\u27f8":e.MO.WIDEREL,"\u27f9":e.MO.WIDEREL,"\u27fa":e.MO.WIDEREL,"\u27fb":e.MO.WIDEREL,"\u27fc":e.MO.WIDEREL,"\u27fd":e.MO.WIDEREL,"\u27fe":e.MO.WIDEREL,"\u27ff":e.MO.WIDEREL,"\u2900":e.MO.RELACCENT,"\u2901":e.MO.RELACCENT,"\u2902":e.MO.RELACCENT,"\u2903":e.MO.RELACCENT,"\u2904":e.MO.RELACCENT,"\u2905":e.MO.RELACCENT,"\u2906":e.MO.RELACCENT,"\u2907":e.MO.RELACCENT,"\u2908":e.MO.REL,"\u2909":e.MO.REL,"\u290a":e.MO.RELSTRETCH,"\u290b":e.MO.RELSTRETCH,"\u290c":e.MO.WIDEREL,"\u290d":e.MO.WIDEREL,"\u290e":e.MO.WIDEREL,"\u290f":e.MO.WIDEREL,"\u2910":e.MO.WIDEREL,"\u2911":e.MO.RELACCENT,"\u2912":e.MO.RELSTRETCH,"\u2913":e.MO.RELSTRETCH,"\u2914":e.MO.RELACCENT,"\u2915":e.MO.RELACCENT,"\u2916":e.MO.RELACCENT,"\u2917":e.MO.RELACCENT,"\u2918":e.MO.RELACCENT,"\u2919":e.MO.RELACCENT,"\u291a":e.MO.RELACCENT,"\u291b":e.MO.RELACCENT,"\u291c":e.MO.RELACCENT,"\u291d":e.MO.RELACCENT,"\u291e":e.MO.RELACCENT,"\u291f":e.MO.RELACCENT,"\u2920":e.MO.RELACCENT,"\u2921":e.MO.RELSTRETCH,"\u2922":e.MO.RELSTRETCH,"\u2923":e.MO.REL,"\u2924":e.MO.REL,"\u2925":e.MO.REL,"\u2926":e.MO.REL,"\u2927":e.MO.REL,"\u2928":e.MO.REL,"\u2929":e.MO.REL,"\u292a":e.MO.REL,"\u292b":e.MO.REL,"\u292c":e.MO.REL,"\u292d":e.MO.REL,"\u292e":e.MO.REL,"\u292f":e.MO.REL,"\u2930":e.MO.REL,"\u2931":e.MO.REL,"\u2932":e.MO.REL,"\u2933":e.MO.RELACCENT,"\u2934":e.MO.REL,"\u2935":e.MO.REL,"\u2936":e.MO.REL,"\u2937":e.MO.REL,"\u2938":e.MO.REL,"\u2939":e.MO.REL,"\u293a":e.MO.RELACCENT,"\u293b":e.MO.RELACCENT,"\u293c":e.MO.RELACCENT,"\u293d":e.MO.RELACCENT,"\u293e":e.MO.REL,"\u293f":e.MO.REL,"\u2940":e.MO.REL,"\u2941":e.MO.REL,"\u2942":e.MO.RELACCENT,"\u2943":e.MO.RELACCENT,"\u2944":e.MO.RELACCENT,"\u2945":e.MO.RELACCENT,"\u2946":e.MO.RELACCENT,"\u2947":e.MO.RELACCENT,"\u2948":e.MO.RELACCENT,"\u2949":e.MO.REL,"\u294a":e.MO.RELACCENT,"\u294b":e.MO.RELACCENT,"\u294c":e.MO.REL,"\u294d":e.MO.REL,"\u294e":e.MO.WIDEREL,"\u294f":e.MO.RELSTRETCH,"\u2950":e.MO.WIDEREL,"\u2951":e.MO.RELSTRETCH,"\u2952":e.MO.WIDEREL,"\u2953":e.MO.WIDEREL,"\u2954":e.MO.RELSTRETCH,"\u2955":e.MO.RELSTRETCH,"\u2956":e.MO.RELSTRETCH,"\u2957":e.MO.RELSTRETCH,"\u2958":e.MO.RELSTRETCH,"\u2959":e.MO.RELSTRETCH,"\u295a":e.MO.WIDEREL,"\u295b":e.MO.WIDEREL,"\u295c":e.MO.RELSTRETCH,"\u295d":e.MO.RELSTRETCH,"\u295e":e.MO.WIDEREL,"\u295f":e.MO.WIDEREL,"\u2960":e.MO.RELSTRETCH,"\u2961":e.MO.RELSTRETCH,"\u2962":e.MO.RELACCENT,"\u2963":e.MO.REL,"\u2964":e.MO.RELACCENT,"\u2965":e.MO.REL,"\u2966":e.MO.RELACCENT,"\u2967":e.MO.RELACCENT,"\u2968":e.MO.RELACCENT,"\u2969":e.MO.RELACCENT,"\u296a":e.MO.RELACCENT,"\u296b":e.MO.RELACCENT,"\u296c":e.MO.RELACCENT,"\u296d":e.MO.RELACCENT,"\u296e":e.MO.RELSTRETCH,"\u296f":e.MO.RELSTRETCH,"\u2970":e.MO.RELACCENT,"\u2971":e.MO.RELACCENT,"\u2972":e.MO.RELACCENT,"\u2973":e.MO.RELACCENT,"\u2974":e.MO.RELACCENT,"\u2975":e.MO.RELACCENT,"\u2976":e.MO.RELACCENT,"\u2977":e.MO.RELACCENT,"\u2978":e.MO.RELACCENT,"\u2979":e.MO.RELACCENT,"\u297a":e.MO.RELACCENT,"\u297b":e.MO.RELACCENT,"\u297c":e.MO.RELACCENT,"\u297d":e.MO.RELACCENT,"\u297e":e.MO.REL,"\u297f":e.MO.REL,"\u2981":e.MO.BIN3,"\u2982":e.MO.BIN3,"\u2999":e.MO.BIN3,"\u299a":e.MO.BIN3,"\u299b":e.MO.BIN3,"\u299c":e.MO.BIN3,"\u299d":e.MO.BIN3,"\u299e":e.MO.BIN3,"\u299f":e.MO.BIN3,"\u29a0":e.MO.BIN3,"\u29a1":e.MO.BIN3,"\u29a2":e.MO.BIN3,"\u29a3":e.MO.BIN3,"\u29a4":e.MO.BIN3,"\u29a5":e.MO.BIN3,"\u29a6":e.MO.BIN3,"\u29a7":e.MO.BIN3,"\u29a8":e.MO.BIN3,"\u29a9":e.MO.BIN3,"\u29aa":e.MO.BIN3,"\u29ab":e.MO.BIN3,"\u29ac":e.MO.BIN3,"\u29ad":e.MO.BIN3,"\u29ae":e.MO.BIN3,"\u29af":e.MO.BIN3,"\u29b0":e.MO.BIN3,"\u29b1":e.MO.BIN3,"\u29b2":e.MO.BIN3,"\u29b3":e.MO.BIN3,"\u29b4":e.MO.BIN3,"\u29b5":e.MO.BIN3,"\u29b6":e.MO.BIN4,"\u29b7":e.MO.BIN4,"\u29b8":e.MO.BIN4,"\u29b9":e.MO.BIN4,"\u29ba":e.MO.BIN4,"\u29bb":e.MO.BIN4,"\u29bc":e.MO.BIN4,"\u29bd":e.MO.BIN4,"\u29be":e.MO.BIN4,"\u29bf":e.MO.BIN4,"\u29c0":e.MO.REL,"\u29c1":e.MO.REL,"\u29c2":e.MO.BIN3,"\u29c3":e.MO.BIN3,"\u29c4":e.MO.BIN4,"\u29c5":e.MO.BIN4,"\u29c6":e.MO.BIN4,"\u29c7":e.MO.BIN4,"\u29c8":e.MO.BIN4,"\u29c9":e.MO.BIN3,"\u29ca":e.MO.BIN3,"\u29cb":e.MO.BIN3,"\u29cc":e.MO.BIN3,"\u29cd":e.MO.BIN3,"\u29ce":e.MO.REL,"\u29cf":e.MO.REL,"\u29cf\u0338":e.MO.REL,"\u29d0":e.MO.REL,"\u29d0\u0338":e.MO.REL,"\u29d1":e.MO.REL,"\u29d2":e.MO.REL,"\u29d3":e.MO.REL,"\u29d4":e.MO.REL,"\u29d5":e.MO.REL,"\u29d6":e.MO.BIN4,"\u29d7":e.MO.BIN4,"\u29d8":e.MO.BIN3,"\u29d9":e.MO.BIN3,"\u29db":e.MO.BIN3,"\u29dc":e.MO.BIN3,"\u29dd":e.MO.BIN3,"\u29de":e.MO.REL,"\u29df":e.MO.BIN3,"\u29e0":e.MO.BIN3,"\u29e1":e.MO.REL,"\u29e2":e.MO.BIN4,"\u29e3":e.MO.REL,"\u29e4":e.MO.REL,"\u29e5":e.MO.REL,"\u29e6":e.MO.REL,"\u29e7":e.MO.BIN3,"\u29e8":e.MO.BIN3,"\u29e9":e.MO.BIN3,"\u29ea":e.MO.BIN3,"\u29eb":e.MO.BIN3,"\u29ec":e.MO.BIN3,"\u29ed":e.MO.BIN3,"\u29ee":e.MO.BIN3,"\u29ef":e.MO.BIN3,"\u29f0":e.MO.BIN3,"\u29f1":e.MO.BIN3,"\u29f2":e.MO.BIN3,"\u29f3":e.MO.BIN3,"\u29f4":e.MO.REL,"\u29f5":e.MO.BIN4,"\u29f6":e.MO.BIN4,"\u29f7":e.MO.BIN4,"\u29f8":e.MO.BIN3,"\u29f9":e.MO.BIN3,"\u29fa":e.MO.BIN3,"\u29fb":e.MO.BIN3,"\u29fe":e.MO.BIN4,"\u29ff":e.MO.BIN4,"\u2a1d":e.MO.BIN3,"\u2a1e":e.MO.BIN3,"\u2a1f":e.MO.BIN3,"\u2a20":e.MO.BIN3,"\u2a21":e.MO.BIN3,"\u2a22":e.MO.BIN4,"\u2a23":e.MO.BIN4,"\u2a24":e.MO.BIN4,"\u2a25":e.MO.BIN4,"\u2a26":e.MO.BIN4,"\u2a27":e.MO.BIN4,"\u2a28":e.MO.BIN4,"\u2a29":e.MO.BIN4,"\u2a2a":e.MO.BIN4,"\u2a2b":e.MO.BIN4,"\u2a2c":e.MO.BIN4,"\u2a2d":e.MO.BIN4,"\u2a2e":e.MO.BIN4,"\u2a2f":e.MO.BIN4,"\u2a30":e.MO.BIN4,"\u2a31":e.MO.BIN4,"\u2a32":e.MO.BIN4,"\u2a33":e.MO.BIN4,"\u2a34":e.MO.BIN4,"\u2a35":e.MO.BIN4,"\u2a36":e.MO.BIN4,"\u2a37":e.MO.BIN4,"\u2a38":e.MO.BIN4,"\u2a39":e.MO.BIN4,"\u2a3a":e.MO.BIN4,"\u2a3b":e.MO.BIN4,"\u2a3c":e.MO.BIN4,"\u2a3d":e.MO.BIN4,"\u2a3e":e.MO.BIN4,"\u2a3f":e.MO.BIN4,"\u2a40":e.MO.BIN4,"\u2a41":e.MO.BIN4,"\u2a42":e.MO.BIN4,"\u2a43":e.MO.BIN4,"\u2a44":e.MO.BIN4,"\u2a45":e.MO.BIN4,"\u2a46":e.MO.BIN4,"\u2a47":e.MO.BIN4,"\u2a48":e.MO.BIN4,"\u2a49":e.MO.BIN4,"\u2a4a":e.MO.BIN4,"\u2a4b":e.MO.BIN4,"\u2a4c":e.MO.BIN4,"\u2a4d":e.MO.BIN4,"\u2a4e":e.MO.BIN4,"\u2a4f":e.MO.BIN4,"\u2a50":e.MO.BIN4,"\u2a51":e.MO.BIN4,"\u2a52":e.MO.BIN4,"\u2a53":e.MO.BIN4,"\u2a54":e.MO.BIN4,"\u2a55":e.MO.BIN4,"\u2a56":e.MO.BIN4,"\u2a57":e.MO.BIN4,"\u2a58":e.MO.BIN4,"\u2a59":e.MO.REL,"\u2a5a":e.MO.BIN4,"\u2a5b":e.MO.BIN4,"\u2a5c":e.MO.BIN4,"\u2a5d":e.MO.BIN4,"\u2a5e":e.MO.BIN4,"\u2a5f":e.MO.BIN4,"\u2a60":e.MO.BIN4,"\u2a61":e.MO.BIN4,"\u2a62":e.MO.BIN4,"\u2a63":e.MO.BIN4,"\u2a64":e.MO.BIN4,"\u2a65":e.MO.BIN4,"\u2a66":e.MO.REL,"\u2a67":e.MO.REL,"\u2a68":e.MO.REL,"\u2a69":e.MO.REL,"\u2a6a":e.MO.REL,"\u2a6b":e.MO.REL,"\u2a6c":e.MO.REL,"\u2a6d":e.MO.REL,"\u2a6e":e.MO.REL,"\u2a6f":e.MO.REL,"\u2a70":e.MO.REL,"\u2a71":e.MO.BIN4,"\u2a72":e.MO.BIN4,"\u2a73":e.MO.REL,"\u2a74":e.MO.REL,"\u2a75":e.MO.REL,"\u2a76":e.MO.REL,"\u2a77":e.MO.REL,"\u2a78":e.MO.REL,"\u2a79":e.MO.REL,"\u2a7a":e.MO.REL,"\u2a7b":e.MO.REL,"\u2a7c":e.MO.REL,"\u2a7d":e.MO.REL,"\u2a7d\u0338":e.MO.REL,"\u2a7e":e.MO.REL,"\u2a7e\u0338":e.MO.REL,"\u2a7f":e.MO.REL,"\u2a80":e.MO.REL,"\u2a81":e.MO.REL,"\u2a82":e.MO.REL,"\u2a83":e.MO.REL,"\u2a84":e.MO.REL,"\u2a85":e.MO.REL,"\u2a86":e.MO.REL,"\u2a87":e.MO.REL,"\u2a88":e.MO.REL,"\u2a89":e.MO.REL,"\u2a8a":e.MO.REL,"\u2a8b":e.MO.REL,"\u2a8c":e.MO.REL,"\u2a8d":e.MO.REL,"\u2a8e":e.MO.REL,"\u2a8f":e.MO.REL,"\u2a90":e.MO.REL,"\u2a91":e.MO.REL,"\u2a92":e.MO.REL,"\u2a93":e.MO.REL,"\u2a94":e.MO.REL,"\u2a95":e.MO.REL,"\u2a96":e.MO.REL,"\u2a97":e.MO.REL,"\u2a98":e.MO.REL,"\u2a99":e.MO.REL,"\u2a9a":e.MO.REL,"\u2a9b":e.MO.REL,"\u2a9c":e.MO.REL,"\u2a9d":e.MO.REL,"\u2a9e":e.MO.REL,"\u2a9f":e.MO.REL,"\u2aa0":e.MO.REL,"\u2aa1":e.MO.REL,"\u2aa1\u0338":e.MO.REL,"\u2aa2":e.MO.REL,"\u2aa2\u0338":e.MO.REL,"\u2aa3":e.MO.REL,"\u2aa4":e.MO.REL,"\u2aa5":e.MO.REL,"\u2aa6":e.MO.REL,"\u2aa7":e.MO.REL,"\u2aa8":e.MO.REL,"\u2aa9":e.MO.REL,"\u2aaa":e.MO.REL,"\u2aab":e.MO.REL,"\u2aac":e.MO.REL,"\u2aad":e.MO.REL,"\u2aae":e.MO.REL,"\u2aaf":e.MO.REL,"\u2aaf\u0338":e.MO.REL,"\u2ab0":e.MO.REL,"\u2ab0\u0338":e.MO.REL,"\u2ab1":e.MO.REL,"\u2ab2":e.MO.REL,"\u2ab3":e.MO.REL,"\u2ab4":e.MO.REL,"\u2ab5":e.MO.REL,"\u2ab6":e.MO.REL,"\u2ab7":e.MO.REL,"\u2ab8":e.MO.REL,"\u2ab9":e.MO.REL,"\u2aba":e.MO.REL,"\u2abb":e.MO.REL,"\u2abc":e.MO.REL,"\u2abd":e.MO.REL,"\u2abe":e.MO.REL,"\u2abf":e.MO.REL,"\u2ac0":e.MO.REL,"\u2ac1":e.MO.REL,"\u2ac2":e.MO.REL,"\u2ac3":e.MO.REL,"\u2ac4":e.MO.REL,"\u2ac5":e.MO.REL,"\u2ac6":e.MO.REL,"\u2ac7":e.MO.REL,"\u2ac8":e.MO.REL,"\u2ac9":e.MO.REL,"\u2aca":e.MO.REL,"\u2acb":e.MO.REL,"\u2acc":e.MO.REL,"\u2acd":e.MO.REL,"\u2ace":e.MO.REL,"\u2acf":e.MO.REL,"\u2ad0":e.MO.REL,"\u2ad1":e.MO.REL,"\u2ad2":e.MO.REL,"\u2ad3":e.MO.REL,"\u2ad4":e.MO.REL,"\u2ad5":e.MO.REL,"\u2ad6":e.MO.REL,"\u2ad7":e.MO.REL,"\u2ad8":e.MO.REL,"\u2ad9":e.MO.REL,"\u2ada":e.MO.REL,"\u2adb":e.MO.REL,"\u2add":e.MO.REL,"\u2add\u0338":e.MO.REL,"\u2ade":e.MO.REL,"\u2adf":e.MO.REL,"\u2ae0":e.MO.REL,"\u2ae1":e.MO.REL,"\u2ae2":e.MO.REL,"\u2ae3":e.MO.REL,"\u2ae4":e.MO.REL,"\u2ae5":e.MO.REL,"\u2ae6":e.MO.REL,"\u2ae7":e.MO.REL,"\u2ae8":e.MO.REL,"\u2ae9":e.MO.REL,"\u2aea":e.MO.REL,"\u2aeb":e.MO.REL,"\u2aec":e.MO.REL,"\u2aed":e.MO.REL,"\u2aee":e.MO.REL,"\u2aef":e.MO.REL,"\u2af0":e.MO.REL,"\u2af1":e.MO.REL,"\u2af2":e.MO.REL,"\u2af3":e.MO.REL,"\u2af4":e.MO.BIN4,"\u2af5":e.MO.BIN4,"\u2af6":e.MO.BIN4,"\u2af7":e.MO.REL,"\u2af8":e.MO.REL,"\u2af9":e.MO.REL,"\u2afa":e.MO.REL,"\u2afb":e.MO.BIN4,"\u2afd":e.MO.BIN4,"\u2afe":e.MO.BIN3,"\u2b45":e.MO.RELSTRETCH,"\u2b46":e.MO.RELSTRETCH,"\u3008":e.MO.OPEN,"\u3009":e.MO.CLOSE,"\ufe37":e.MO.WIDEACCENT,"\ufe38":e.MO.WIDEACCENT}},e.OPTABLE.infix["^"]=e.MO.WIDEREL,e.OPTABLE.infix._=e.MO.WIDEREL,e.OPTABLE.infix["\u2adc"]=e.MO.REL},9259:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.SerializedMmlVisitor=e.toEntity=e.DATAMJX=void 0;var a=r(6325),l=r(9007),c=r(450);e.DATAMJX="data-mjx-";e.toEntity=function(t){return"&#x"+t.codePointAt(0).toString(16).toUpperCase()+";"};var u=function(t){function r(){return null!==t&&t.apply(this,arguments)||this}return o(r,t),r.prototype.visitTree=function(t){return this.visitNode(t,"")},r.prototype.visitTextNode=function(t,e){return this.quoteHTML(t.getText())},r.prototype.visitXMLNode=function(t,e){return e+t.getSerializedXML()},r.prototype.visitInferredMrowNode=function(t,e){var r,n,o=[];try{for(var s=i(t.childNodes),a=s.next();!a.done;a=s.next()){var l=a.value;o.push(this.visitNode(l,e))}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=s.return)&&n.call(s)}finally{if(r)throw r.error}}return o.join("\n")},r.prototype.visitTeXAtomNode=function(t,e){var r=this.childNodeMml(t,e+" ","\n");return e+""+(r.match(/\S/)?"\n"+r+e:"")+""},r.prototype.visitAnnotationNode=function(t,e){return e+""+this.childNodeMml(t,"","")+""},r.prototype.visitDefault=function(t,e){var r=t.kind,n=s(t.isToken||0===t.childNodes.length?["",""]:["\n",e],2),o=n[0],i=n[1],a=this.childNodeMml(t,e+" ",o);return e+"<"+r+this.getAttributes(t)+">"+(a.match(/\S/)?o+a+i:"")+""},r.prototype.childNodeMml=function(t,e,r){var n,o,s="";try{for(var a=i(t.childNodes),l=a.next();!l.done;l=a.next()){var c=l.value;s+=this.visitNode(c,e)+r}}catch(t){n={error:t}}finally{try{l&&!l.done&&(o=a.return)&&o.call(a)}finally{if(n)throw n.error}}return s},r.prototype.getAttributes=function(t){var e,r,n=[],o=this.constructor.defaultAttributes[t.kind]||{},s=Object.assign({},o,this.getDataAttributes(t),t.attributes.getAllAttributes()),a=this.constructor.variants;s.hasOwnProperty("mathvariant")&&a.hasOwnProperty(s.mathvariant)&&(s.mathvariant=a[s.mathvariant]);try{for(var l=i(Object.keys(s)),c=l.next();!c.done;c=l.next()){var u=c.value,p=String(s[u]);void 0!==p&&n.push(u+'="'+this.quoteHTML(p)+'"')}}catch(t){e={error:t}}finally{try{c&&!c.done&&(r=l.return)&&r.call(l)}finally{if(e)throw e.error}}return n.length?" "+n.join(" "):""},r.prototype.getDataAttributes=function(t){var e={},r=t.attributes.getExplicit("mathvariant"),n=this.constructor.variants;r&&n.hasOwnProperty(r)&&this.setDataAttribute(e,"variant",r),t.getProperty("variantForm")&&this.setDataAttribute(e,"alternate","1"),t.getProperty("pseudoscript")&&this.setDataAttribute(e,"pseudoscript","true"),!1===t.getProperty("autoOP")&&this.setDataAttribute(e,"auto-op","false");var o=t.getProperty("scriptalign");o&&this.setDataAttribute(e,"script-align",o);var i=t.getProperty("texClass");if(void 0!==i){var s=!0;if(i===l.TEXCLASS.OP&&t.isKind("mi")){var a=t.getText();s=!(a.length>1&&a.match(c.MmlMi.operatorName))}s&&this.setDataAttribute(e,"texclass",i<0?"NONE":l.TEXCLASSNAMES[i])}return t.getProperty("scriptlevel")&&!1===t.getProperty("useHeight")&&this.setDataAttribute(e,"smallmatrix","true"),e},r.prototype.setDataAttribute=function(t,r,n){t[e.DATAMJX+r]=n},r.prototype.quoteHTML=function(t){return t.replace(/&/g,"&").replace(//g,">").replace(/\"/g,""").replace(/[\uD800-\uDBFF]./g,e.toEntity).replace(/[\u0080-\uD7FF\uE000-\uFFFF]/g,e.toEntity)},r.variants={"-tex-calligraphic":"script","-tex-bold-calligraphic":"bold-script","-tex-oldstyle":"normal","-tex-bold-oldstyle":"bold","-tex-mathit":"italic"},r.defaultAttributes={math:{xmlns:"http://www.w3.org/1998/Math/MathML"}},r}(a.MmlVisitor);e.SerializedMmlVisitor=u},2975:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractOutputJax=void 0;var n=r(7233),o=r(7525),i=function(){function t(t){void 0===t&&(t={}),this.adaptor=null;var e=this.constructor;this.options=(0,n.userOptions)((0,n.defaultOptions)({},e.OPTIONS),t),this.postFilters=new o.FunctionList}return Object.defineProperty(t.prototype,"name",{get:function(){return this.constructor.NAME},enumerable:!1,configurable:!0}),t.prototype.setAdaptor=function(t){this.adaptor=t},t.prototype.initialize=function(){},t.prototype.reset=function(){for(var t=[],e=0;e=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractEmptyNode=e.AbstractNode=void 0;var s=function(){function t(t,e,r){var n,o;void 0===e&&(e={}),void 0===r&&(r=[]),this.factory=t,this.parent=null,this.properties={},this.childNodes=[];try{for(var s=i(Object.keys(e)),a=s.next();!a.done;a=s.next()){var l=a.value;this.setProperty(l,e[l])}}catch(t){n={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(n)throw n.error}}r.length&&this.setChildren(r)}return Object.defineProperty(t.prototype,"kind",{get:function(){return"unknown"},enumerable:!1,configurable:!0}),t.prototype.setProperty=function(t,e){this.properties[t]=e},t.prototype.getProperty=function(t){return this.properties[t]},t.prototype.getPropertyNames=function(){return Object.keys(this.properties)},t.prototype.getAllProperties=function(){return this.properties},t.prototype.removeProperty=function(){for(var t,e,r=[],n=0;n=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLDocument=void 0;var l=r(5722),c=r(7233),u=r(3363),p=r(3335),h=r(5138),f=r(4474),d=function(t){function e(e,r,n){var o=this,i=s((0,c.separateOptions)(n,h.HTMLDomStrings.OPTIONS),2),a=i[0],l=i[1];return(o=t.call(this,e,r,a)||this).domStrings=o.options.DomStrings||new h.HTMLDomStrings(l),o.domStrings.adaptor=r,o.styles=[],o}return o(e,t),e.prototype.findPosition=function(t,e,r,n){var o,i,l=this.adaptor;try{for(var c=a(n[t]),u=c.next();!u.done;u=c.next()){var p=u.value,h=s(p,2),f=h[0],d=h[1];if(e<=d&&"#text"===l.kind(f))return{node:f,n:Math.max(e,0),delim:r};e-=d}}catch(t){o={error:t}}finally{try{u&&!u.done&&(i=c.return)&&i.call(c)}finally{if(o)throw o.error}}return{node:null,n:0,delim:r}},e.prototype.mathItem=function(t,e,r){var n=t.math,o=this.findPosition(t.n,t.start.n,t.open,r),i=this.findPosition(t.n,t.end.n,t.close,r);return new this.options.MathItem(n,e,t.display,o,i)},e.prototype.findMath=function(t){var e,r,n,o,i,l,u,p,h;if(!this.processed.isSet("findMath")){this.adaptor.document=this.document,t=(0,c.userOptions)({elements:this.options.elements||[this.adaptor.body(this.document)]},t);try{for(var f=a(this.adaptor.getElements(t.elements,this.document)),d=f.next();!d.done;d=f.next()){var m=d.value,y=s([null,null],2),g=y[0],b=y[1];try{for(var v=(n=void 0,a(this.inputJax)),_=v.next();!_.done;_=v.next()){var S=_.value,M=new this.options.MathList;if(S.processStrings){null===g&&(g=(i=s(this.domStrings.find(m),2))[0],b=i[1]);try{for(var O=(l=void 0,a(S.findMath(g))),x=O.next();!x.done;x=O.next()){var E=x.value;M.push(this.mathItem(E,S,b))}}catch(t){l={error:t}}finally{try{x&&!x.done&&(u=O.return)&&u.call(O)}finally{if(l)throw l.error}}}else try{for(var A=(p=void 0,a(S.findMath(m))),C=A.next();!C.done;C=A.next()){E=C.value;var T=new this.options.MathItem(E.math,S,E.display,E.start,E.end);M.push(T)}}catch(t){p={error:t}}finally{try{C&&!C.done&&(h=A.return)&&h.call(A)}finally{if(p)throw p.error}}this.math.merge(M)}}catch(t){n={error:t}}finally{try{_&&!_.done&&(o=v.return)&&o.call(v)}finally{if(n)throw n.error}}}}catch(t){e={error:t}}finally{try{d&&!d.done&&(r=f.return)&&r.call(f)}finally{if(e)throw e.error}}this.processed.set("findMath")}return this},e.prototype.updateDocument=function(){return this.processed.isSet("updateDocument")||(this.addPageElements(),this.addStyleSheet(),t.prototype.updateDocument.call(this),this.processed.set("updateDocument")),this},e.prototype.addPageElements=function(){var t=this.adaptor.body(this.document),e=this.documentPageElements();e&&this.adaptor.append(t,e)},e.prototype.addStyleSheet=function(){var t=this.documentStyleSheet(),e=this.adaptor;if(t&&!e.parent(t)){var r=e.head(this.document),n=this.findSheet(r,e.getAttribute(t,"id"));n?e.replace(t,n):e.append(r,t)}},e.prototype.findSheet=function(t,e){var r,n;if(e)try{for(var o=a(this.adaptor.tags(t,"style")),i=o.next();!i.done;i=o.next()){var s=i.value;if(this.adaptor.getAttribute(s,"id")===e)return s}}catch(t){r={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}return null},e.prototype.removeFromDocument=function(t){var e,r;if(void 0===t&&(t=!1),this.processed.isSet("updateDocument"))try{for(var n=a(this.math),o=n.next();!o.done;o=n.next()){var i=o.value;i.state()>=f.STATE.INSERTED&&i.state(f.STATE.TYPESET,t)}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}return this.processed.clear("updateDocument"),this},e.prototype.documentStyleSheet=function(){return this.outputJax.styleSheet(this)},e.prototype.documentPageElements=function(){return this.outputJax.pageElements(this)},e.prototype.addStyles=function(t){this.styles.push(t)},e.prototype.getStyles=function(){return this.styles},e.KIND="HTML",e.OPTIONS=i(i({},l.AbstractMathDocument.OPTIONS),{renderActions:(0,c.expandable)(i(i({},l.AbstractMathDocument.OPTIONS.renderActions),{styles:[f.STATE.INSERTED+1,"","updateStyleSheet",!1]})),MathList:p.HTMLMathList,MathItem:u.HTMLMathItem,DomStrings:null}),e}(l.AbstractMathDocument);e.HTMLDocument=d},5138:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLDomStrings=void 0;var o=r(7233),i=function(){function t(t){void 0===t&&(t=null);var e=this.constructor;this.options=(0,o.userOptions)((0,o.defaultOptions)({},e.OPTIONS),t),this.init(),this.getPatterns()}return t.prototype.init=function(){this.strings=[],this.string="",this.snodes=[],this.nodes=[],this.stack=[]},t.prototype.getPatterns=function(){var t=(0,o.makeArray)(this.options.skipHtmlTags),e=(0,o.makeArray)(this.options.ignoreHtmlClass),r=(0,o.makeArray)(this.options.processHtmlClass);this.skipHtmlTags=new RegExp("^(?:"+t.join("|")+")$","i"),this.ignoreHtmlClass=new RegExp("(?:^| )(?:"+e.join("|")+")(?: |$)"),this.processHtmlClass=new RegExp("(?:^| )(?:"+r+")(?: |$)")},t.prototype.pushString=function(){this.string.match(/\S/)&&(this.strings.push(this.string),this.nodes.push(this.snodes)),this.string="",this.snodes=[]},t.prototype.extendString=function(t,e){this.snodes.push([t,e.length]),this.string+=e},t.prototype.handleText=function(t,e){return e||this.extendString(t,this.adaptor.value(t)),this.adaptor.next(t)},t.prototype.handleTag=function(t,e){if(!e){var r=this.options.includeHtmlTags[this.adaptor.kind(t)];this.extendString(t,r)}return this.adaptor.next(t)},t.prototype.handleContainer=function(t,e){this.pushString();var r=this.adaptor.getAttribute(t,"class")||"",n=this.adaptor.kind(t)||"",o=this.processHtmlClass.exec(r),i=t;return!this.adaptor.firstChild(t)||this.adaptor.getAttribute(t,"data-MJX")||!o&&this.skipHtmlTags.exec(n)?i=this.adaptor.next(t):(this.adaptor.next(t)&&this.stack.push([this.adaptor.next(t),e]),i=this.adaptor.firstChild(t),e=(e||this.ignoreHtmlClass.exec(r))&&!o),[i,e]},t.prototype.handleOther=function(t,e){return this.pushString(),this.adaptor.next(t)},t.prototype.find=function(t){var e,r;this.init();for(var o=this.adaptor.next(t),i=!1,s=this.options.includeHtmlTags;t&&t!==o;){var a=this.adaptor.kind(t);"#text"===a?t=this.handleText(t,i):s.hasOwnProperty(a)?t=this.handleTag(t,i):a?(t=(e=n(this.handleContainer(t,i),2))[0],i=e[1]):t=this.handleOther(t,i),!t&&this.stack.length&&(this.pushString(),t=(r=n(this.stack.pop(),2))[0],i=r[1])}this.pushString();var l=[this.strings,this.nodes];return this.init(),l},t.OPTIONS={skipHtmlTags:["script","noscript","style","textarea","pre","code","annotation","annotation-xml"],includeHtmlTags:{br:"\n",wbr:"","#comment":""},ignoreHtmlClass:"mathjax_ignore",processHtmlClass:"mathjax_process"},t}();e.HTMLDomStrings=i},3726:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLHandler=void 0;var i=r(3670),s=r(3683),a=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.documentClass=s.HTMLDocument,e}return o(e,t),e.prototype.handlesDocument=function(t){var e=this.adaptor;if("string"==typeof t)try{t=e.parse(t,"text/html")}catch(t){}return t instanceof e.window.Document||t instanceof e.window.HTMLElement||t instanceof e.window.DocumentFragment},e.prototype.create=function(e,r){var n=this.adaptor;if("string"==typeof e)e=n.parse(e,"text/html");else if(e instanceof n.window.HTMLElement||e instanceof n.window.DocumentFragment){var o=e;e=n.parse("","text/html"),n.append(n.body(e),o)}return t.prototype.create.call(this,e,r)},e}(i.AbstractHandler);e.HTMLHandler=a},3363:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLMathItem=void 0;var i=r(4474),s=function(t){function e(e,r,n,o,i){return void 0===n&&(n=!0),void 0===o&&(o={node:null,n:0,delim:""}),void 0===i&&(i={node:null,n:0,delim:""}),t.call(this,e,r,n,o,i)||this}return o(e,t),Object.defineProperty(e.prototype,"adaptor",{get:function(){return this.inputJax.adaptor},enumerable:!1,configurable:!0}),e.prototype.updateDocument=function(t){if(this.state()=i.STATE.TYPESET){var e=this.adaptor,r=this.start.node,n=e.text("");if(t){var o=this.start.delim+this.math+this.end.delim;if(this.inputJax.processStrings)n=e.text(o);else{var s=e.parse(o,"text/html");n=e.firstChild(e.body(s))}}e.parent(r)&&e.replace(n,r),this.start.node=this.end.node=n,this.start.n=this.end.n=0}},e}(i.AbstractMathItem);e.HTMLMathItem=s},3335:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.HTMLMathList=void 0;var i=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e}(r(9e3).AbstractMathList);e.HTMLMathList=i},2892:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.MathML=void 0;var s=r(9206),a=r(7233),l=r(7525),c=r(625),u=r(2769),p=function(t){function e(e){void 0===e&&(e={});var r=this,n=i((0,a.separateOptions)(e,c.FindMathML.OPTIONS,u.MathMLCompile.OPTIONS),3),o=n[0],s=n[1],p=n[2];return(r=t.call(this,o)||this).findMathML=r.options.FindMathML||new c.FindMathML(s),r.mathml=r.options.MathMLCompile||new u.MathMLCompile(p),r.mmlFilters=new l.FunctionList,r}return o(e,t),e.prototype.setAdaptor=function(e){t.prototype.setAdaptor.call(this,e),this.findMathML.adaptor=e,this.mathml.adaptor=e},e.prototype.setMmlFactory=function(e){t.prototype.setMmlFactory.call(this,e),this.mathml.setMmlFactory(e)},Object.defineProperty(e.prototype,"processStrings",{get:function(){return!1},enumerable:!1,configurable:!0}),e.prototype.compile=function(t,e){var r=t.start.node;if(!r||!t.end.node||this.options.forceReparse||"#text"===this.adaptor.kind(r)){var n=this.executeFilters(this.preFilters,t,e,(t.math||"").trim()),o=this.checkForErrors(this.adaptor.parse(n,"text/"+this.options.parseAs)),i=this.adaptor.body(o);1!==this.adaptor.childNodes(i).length&&this.error("MathML must consist of a single element"),r=this.adaptor.remove(this.adaptor.firstChild(i)),"math"!==this.adaptor.kind(r).replace(/^[a-z]+:/,"")&&this.error("MathML must be formed by a element, not <"+this.adaptor.kind(r)+">")}return r=this.executeFilters(this.mmlFilters,t,e,r),this.executeFilters(this.postFilters,t,e,this.mathml.compile(r))},e.prototype.checkForErrors=function(t){var e=this.adaptor.tags(this.adaptor.body(t),"parsererror")[0];return e&&(""===this.adaptor.textContent(e)&&this.error("Error processing MathML"),this.options.parseError.call(this,e)),t},e.prototype.error=function(t){throw new Error(t)},e.prototype.findMath=function(t){return this.findMathML.findMath(t)},e.NAME="MathML",e.OPTIONS=(0,a.defaultOptions)({parseAs:"html",forceReparse:!1,FindMathML:null,MathMLCompile:null,parseError:function(t){this.error(this.adaptor.textContent(t).replace(/\n.*/g,""))}},s.AbstractInputJax.OPTIONS),e}(s.AbstractInputJax);e.MathML=p},625:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.FindMathML=void 0;var s=r(3494),a="http://www.w3.org/1998/Math/MathML",l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.findMath=function(t){var e=new Set;this.findMathNodes(t,e),this.findMathPrefixed(t,e);var r=this.adaptor.root(this.adaptor.document);return"html"===this.adaptor.kind(r)&&0===e.size&&this.findMathNS(t,e),this.processMath(e)},e.prototype.findMathNodes=function(t,e){var r,n;try{for(var o=i(this.adaptor.tags(t,"math")),s=o.next();!s.done;s=o.next()){var a=s.value;e.add(a)}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},e.prototype.findMathPrefixed=function(t,e){var r,n,o,s,l=this.adaptor.root(this.adaptor.document);try{for(var c=i(this.adaptor.allAttributes(l)),u=c.next();!u.done;u=c.next()){var p=u.value;if("xmlns:"===p.name.substr(0,6)&&p.value===a){var h=p.name.substr(6);try{for(var f=(o=void 0,i(this.adaptor.tags(t,h+":math"))),d=f.next();!d.done;d=f.next()){var m=d.value;e.add(m)}}catch(t){o={error:t}}finally{try{d&&!d.done&&(s=f.return)&&s.call(f)}finally{if(o)throw o.error}}}}}catch(t){r={error:t}}finally{try{u&&!u.done&&(n=c.return)&&n.call(c)}finally{if(r)throw r.error}}},e.prototype.findMathNS=function(t,e){var r,n;try{for(var o=i(this.adaptor.tags(t,"math",a)),s=o.next();!s.done;s=o.next()){var l=s.value;e.add(l)}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},e.prototype.processMath=function(t){var e,r,n=[];try{for(var o=i(Array.from(t)),s=o.next();!s.done;s=o.next()){var a=s.value,l="block"===this.adaptor.getAttribute(a,"display")||"display"===this.adaptor.getAttribute(a,"mode"),c={node:a,n:0,delim:""},u={node:a,n:0,delim:""};n.push({math:this.adaptor.outerHTML(a),start:c,end:u,display:l})}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}return n},e.OPTIONS={},e}(s.AbstractFindMath);e.FindMathML=l},2769:function(t,e,r){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MathMLCompile=void 0;var l=r(9007),c=r(7233),u=s(r(5368)),p=function(){function t(t){void 0===t&&(t={});var e=this.constructor;this.options=(0,c.userOptions)((0,c.defaultOptions)({},e.OPTIONS),t)}return t.prototype.setMmlFactory=function(t){this.factory=t},t.prototype.compile=function(t){var e=this.makeNode(t);return e.verifyTree(this.options.verify),e.setInheritedAttributes({},!1,0,!1),e.walkTree(this.markMrows),e},t.prototype.makeNode=function(t){var e,r,n=this.adaptor,o=!1,i=n.kind(t).replace(/^.*:/,""),s=n.getAttribute(t,"data-mjx-texclass")||"";s&&(s=this.filterAttribute("data-mjx-texclass",s)||"");var c=s&&"mrow"===i?"TeXAtom":i;try{for(var u=a(this.filterClassList(n.allClasses(t))),p=u.next();!p.done;p=u.next()){var h=p.value;h.match(/^MJX-TeXAtom-/)&&"mrow"===i?(s=h.substr(12),c="TeXAtom"):"MJX-fixedlimits"===h&&(o=!0)}}catch(t){e={error:t}}finally{try{p&&!p.done&&(r=u.return)&&r.call(u)}finally{if(e)throw e.error}}this.factory.getNodeClass(c)||this.error('Unknown node type "'+c+'"');var f=this.factory.create(c);return"TeXAtom"!==c||"OP"!==s||o||(f.setProperty("movesupsub",!0),f.attributes.setInherited("movablelimits",!0)),s&&(f.texClass=l.TEXCLASS[s],f.setProperty("texClass",f.texClass)),this.addAttributes(f,t),this.checkClass(f,t),this.addChildren(f,t),f},t.prototype.addAttributes=function(t,e){var r,n,o=!1;try{for(var i=a(this.adaptor.allAttributes(e)),s=i.next();!s.done;s=i.next()){var l=s.value,c=l.name,u=this.filterAttribute(c,l.value);if(null!==u&&"xmlns"!==c)if("data-mjx-"===c.substr(0,9))switch(c.substr(9)){case"alternate":t.setProperty("variantForm",!0);break;case"variant":t.attributes.set("mathvariant",u),o=!0;break;case"smallmatrix":t.setProperty("scriptlevel",1),t.setProperty("useHeight",!1);break;case"accent":t.setProperty("mathaccent","true"===u);break;case"auto-op":t.setProperty("autoOP","true"===u);break;case"script-align":t.setProperty("scriptalign",u)}else if("class"!==c){var p=u.toLowerCase();"true"===p||"false"===p?t.attributes.set(c,"true"===p):o&&"mathvariant"===c||t.attributes.set(c,u)}}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}},t.prototype.filterAttribute=function(t,e){return e},t.prototype.filterClassList=function(t){return t},t.prototype.addChildren=function(t,e){var r,n;if(0!==t.arity){var o=this.adaptor;try{for(var i=a(o.childNodes(e)),s=i.next();!s.done;s=i.next()){var l=s.value,c=o.kind(l);if("#comment"!==c)if("#text"===c)this.addText(t,l);else if(t.isKind("annotation-xml"))t.appendChild(this.factory.create("XML").setXML(l,o));else{var u=t.appendChild(this.makeNode(l));0===u.arity&&o.childNodes(l).length&&(this.options.fixMisplacedChildren?this.addChildren(t,l):u.mError("There should not be children for "+u.kind+" nodes",this.options.verify,!0))}}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}}},t.prototype.addText=function(t,e){var r=this.adaptor.value(e);(t.isToken||t.getProperty("isChars"))&&t.arity?(t.isToken&&(r=u.translate(r),r=this.trimSpace(r)),t.appendChild(this.factory.create("text").setText(r))):r.match(/\S/)&&this.error('Unexpected text node "'+r+'"')},t.prototype.checkClass=function(t,e){var r,n,o=[];try{for(var i=a(this.filterClassList(this.adaptor.allClasses(e))),s=i.next();!s.done;s=i.next()){var l=s.value;"MJX-"===l.substr(0,4)?"MJX-variant"===l?t.setProperty("variantForm",!0):"MJX-TeXAtom"!==l.substr(0,11)&&t.attributes.set("mathvariant",this.fixCalligraphic(l.substr(3))):o.push(l)}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}o.length&&t.attributes.set("class",o.join(" "))},t.prototype.fixCalligraphic=function(t){return t.replace(/caligraphic/,"calligraphic")},t.prototype.markMrows=function(t){if(t.isKind("mrow")&&!t.isInferred&&t.childNodes.length>=2){var e=t.childNodes[0],r=t.childNodes[t.childNodes.length-1];e.isKind("mo")&&e.attributes.get("fence")&&e.attributes.get("stretchy")&&r.isKind("mo")&&r.attributes.get("fence")&&r.attributes.get("stretchy")&&(e.childNodes.length&&t.setProperty("open",e.getText()),r.childNodes.length&&t.setProperty("close",r.getText()))}},t.prototype.trimSpace=function(t){return t.replace(/[\t\n\r]/g," ").replace(/^ +/,"").replace(/ +$/,"").replace(/ +/g," ")},t.prototype.error=function(t){throw new Error(t)},t.OPTIONS={MmlFactory:null,fixMisplacedChildren:!0,verify:n({},l.AbstractMmlNode.verifyDefaults),translateEntities:!0},t}();e.MathMLCompile=p},8462:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.TeX=void 0;var l=r(9206),c=r(7233),u=r(7073),p=a(r(4676)),h=a(r(1256)),f=a(r(8417)),d=a(r(3971)),m=a(r(8562)),y=r(6521),g=r(9899);r(2942);var b=function(t){function e(r){void 0===r&&(r={});var n=this,o=s((0,c.separateOptions)(r,e.OPTIONS,u.FindTeX.OPTIONS),3),i=o[0],a=o[1],l=o[2];(n=t.call(this,a)||this).findTeX=n.options.FindTeX||new u.FindTeX(l);var h=n.options.packages,f=n.configuration=e.configure(h),d=n._parseOptions=new m.default(f,[n.options,y.TagsFactory.OPTIONS]);return(0,c.userOptions)(d.options,i),f.config(n),e.tags(d,f),n.postFilters.add(p.default.cleanSubSup,-6),n.postFilters.add(p.default.setInherited,-5),n.postFilters.add(p.default.moveLimits,-4),n.postFilters.add(p.default.cleanStretchy,-3),n.postFilters.add(p.default.cleanAttributes,-2),n.postFilters.add(p.default.combineRelations,-1),n}return o(e,t),e.configure=function(t){var e=new g.ParserConfiguration(t,["tex"]);return e.init(),e},e.tags=function(t,e){y.TagsFactory.addTags(e.tags),y.TagsFactory.setDefault(t.options.tags),t.tags=y.TagsFactory.getDefault(),t.tags.configuration=t},e.prototype.setMmlFactory=function(e){t.prototype.setMmlFactory.call(this,e),this._parseOptions.nodeFactory.setMmlFactory(e)},Object.defineProperty(e.prototype,"parseOptions",{get:function(){return this._parseOptions},enumerable:!1,configurable:!0}),e.prototype.reset=function(t){void 0===t&&(t=0),this.parseOptions.tags.reset(t)},e.prototype.compile=function(t,e){this.parseOptions.clear(),this.executeFilters(this.preFilters,t,e,this.parseOptions);var r,n,o=t.display;this.latex=t.math,this.parseOptions.tags.startEquation(t);try{var i=new f.default(this.latex,{display:o,isInner:!1},this.parseOptions);r=i.mml(),n=i.stack.global}catch(t){if(!(t instanceof d.default))throw t;this.parseOptions.error=!0,r=this.options.formatError(this,t)}return r=this.parseOptions.nodeFactory.create("node","math",[r]),(null==n?void 0:n.indentalign)&&h.default.setAttribute(r,"indentalign",n.indentalign),o&&h.default.setAttribute(r,"display","block"),this.parseOptions.tags.finishEquation(t),this.parseOptions.root=r,this.executeFilters(this.postFilters,t,e,this.parseOptions),this.mathNode=this.parseOptions.root,this.mathNode},e.prototype.findMath=function(t){return this.findTeX.findMath(t)},e.prototype.formatError=function(t){var e=t.message.replace(/\n.*/,"");return this.parseOptions.nodeFactory.create("error",e,t.id,this.latex)},e.NAME="TeX",e.OPTIONS=i(i({},l.AbstractInputJax.OPTIONS),{FindTeX:null,packages:["base"],digits:/^(?:[0-9]+(?:\{,\}[0-9]{3})*(?:\.[0-9]*)?|\.[0-9]+)/,maxBuffer:5120,formatError:function(t,e){return t.formatError(e)}}),e}(l.AbstractInputJax);e.TeX=b},9899:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.ParserConfiguration=e.ConfigurationHandler=e.Configuration=void 0;var i,s=r(7233),a=r(2947),l=r(7525),c=r(8666),u=r(6521),p=function(){function t(t,e,r,n,o,i,s,a,l,c,u,p,h){void 0===e&&(e={}),void 0===r&&(r={}),void 0===n&&(n={}),void 0===o&&(o={}),void 0===i&&(i={}),void 0===s&&(s={}),void 0===a&&(a=[]),void 0===l&&(l=[]),void 0===c&&(c=null),void 0===u&&(u=null),this.name=t,this.handler=e,this.fallback=r,this.items=n,this.tags=o,this.options=i,this.nodes=s,this.preprocessors=a,this.postprocessors=l,this.initMethod=c,this.configMethod=u,this.priority=p,this.parser=h,this.handler=Object.assign({character:[],delimiter:[],macro:[],environment:[]},e)}return t.makeProcessor=function(t,e){return Array.isArray(t)?t:[t,e]},t._create=function(e,r){var n=this;void 0===r&&(r={});var o=r.priority||c.PrioritizedList.DEFAULTPRIORITY,i=r.init?this.makeProcessor(r.init,o):null,s=r.config?this.makeProcessor(r.config,o):null,a=(r.preprocessors||[]).map((function(t){return n.makeProcessor(t,o)})),l=(r.postprocessors||[]).map((function(t){return n.makeProcessor(t,o)})),u=r.parser||"tex";return new t(e,r.handler||{},r.fallback||{},r.items||{},r.tags||{},r.options||{},r.nodes||{},a,l,i,s,o,u)},t.create=function(e,r){void 0===r&&(r={});var n=t._create(e,r);return i.set(e,n),n},t.local=function(e){return void 0===e&&(e={}),t._create("",e)},Object.defineProperty(t.prototype,"init",{get:function(){return this.initMethod?this.initMethod[0]:null},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"config",{get:function(){return this.configMethod?this.configMethod[0]:null},enumerable:!1,configurable:!0}),t}();e.Configuration=p,function(t){var e=new Map;t.set=function(t,r){e.set(t,r)},t.get=function(t){return e.get(t)},t.keys=function(){return e.keys()}}(i=e.ConfigurationHandler||(e.ConfigurationHandler={}));var h=function(){function t(t,e){var r,o,i,s;void 0===e&&(e=["tex"]),this.initMethod=new l.FunctionList,this.configMethod=new l.FunctionList,this.configurations=new c.PrioritizedList,this.parsers=[],this.handlers=new a.SubHandlers,this.items={},this.tags={},this.options={},this.nodes={},this.parsers=e;try{for(var u=n(t.slice().reverse()),p=u.next();!p.done;p=u.next()){var h=p.value;this.addPackage(h)}}catch(t){r={error:t}}finally{try{p&&!p.done&&(o=u.return)&&o.call(u)}finally{if(r)throw r.error}}try{for(var f=n(this.configurations),d=f.next();!d.done;d=f.next()){var m=d.value,y=m.item,g=m.priority;this.append(y,g)}}catch(t){i={error:t}}finally{try{d&&!d.done&&(s=f.return)&&s.call(f)}finally{if(i)throw i.error}}}return t.prototype.init=function(){this.initMethod.execute(this)},t.prototype.config=function(t){var e,r;this.configMethod.execute(this,t);try{for(var o=n(this.configurations),i=o.next();!i.done;i=o.next()){var s=i.value;this.addFilters(t,s.item)}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}},t.prototype.addPackage=function(t){var e="string"==typeof t?t:t[0],r=this.getPackage(e);r&&this.configurations.add(r,"string"==typeof t?r.priority:t[1])},t.prototype.add=function(t,e,r){var o,i;void 0===r&&(r={});var a=this.getPackage(t);this.append(a),this.configurations.add(a,a.priority),this.init();var l=e.parseOptions;l.nodeFactory.setCreators(a.nodes);try{for(var c=n(Object.keys(a.items)),p=c.next();!p.done;p=c.next()){var h=p.value;l.itemFactory.setNodeClass(h,a.items[h])}}catch(t){o={error:t}}finally{try{p&&!p.done&&(i=c.return)&&i.call(c)}finally{if(o)throw o.error}}u.TagsFactory.addTags(a.tags),(0,s.defaultOptions)(l.options,a.options),(0,s.userOptions)(l.options,r),this.addFilters(e,a),a.config&&a.config(this,e)},t.prototype.getPackage=function(t){var e=i.get(t);if(e&&this.parsers.indexOf(e.parser)<0)throw Error("Package ".concat(t," doesn't target the proper parser"));return e},t.prototype.append=function(t,e){e=e||t.priority,t.initMethod&&this.initMethod.add(t.initMethod[0],t.initMethod[1]),t.configMethod&&this.configMethod.add(t.configMethod[0],t.configMethod[1]),this.handlers.add(t.handler,t.fallback,e),Object.assign(this.items,t.items),Object.assign(this.tags,t.tags),(0,s.defaultOptions)(this.options,t.options),Object.assign(this.nodes,t.nodes)},t.prototype.addFilters=function(t,e){var r,i,s,a;try{for(var l=n(e.preprocessors),c=l.next();!c.done;c=l.next()){var u=o(c.value,2),p=u[0],h=u[1];t.preFilters.add(p,h)}}catch(t){r={error:t}}finally{try{c&&!c.done&&(i=l.return)&&i.call(l)}finally{if(r)throw r.error}}try{for(var f=n(e.postprocessors),d=f.next();!d.done;d=f.next()){var m=o(d.value,2),y=m[0];h=m[1];t.postFilters.add(y,h)}}catch(t){s={error:t}}finally{try{d&&!d.done&&(a=f.return)&&a.call(f)}finally{if(s)throw s.error}}},t}();e.ParserConfiguration=h},4676:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var i,s=r(9007),a=o(r(1256));!function(t){t.cleanStretchy=function(t){var e,r,o=t.data;try{for(var i=n(o.getList("fixStretchy")),s=i.next();!s.done;s=i.next()){var l=s.value;if(a.default.getProperty(l,"fixStretchy")){var c=a.default.getForm(l);c&&c[3]&&c[3].stretchy&&a.default.setAttribute(l,"stretchy",!1);var u=l.parent;if(!(a.default.getTexClass(l)||c&&c[2])){var p=o.nodeFactory.create("node","TeXAtom",[l]);u.replaceChild(p,l),p.inheritAttributesFrom(l)}a.default.removeProperties(l,"fixStretchy")}}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}},t.cleanAttributes=function(t){t.data.root.walkTree((function(t,e){var r,o,i=t.attributes;if(i){var s=new Set((i.get("mjx-keep-attrs")||"").split(/ /));delete i.getAllAttributes()["mjx-keep-attrs"];try{for(var a=n(i.getExplicitNames()),l=a.next();!l.done;l=a.next()){var c=l.value;s.has(c)||i.attributes[c]!==t.attributes.getInherited(c)||delete i.attributes[c]}}catch(t){r={error:t}}finally{try{l&&!l.done&&(o=a.return)&&o.call(a)}finally{if(r)throw r.error}}}}),{})},t.combineRelations=function(t){var o,i,l,c,u=[];try{for(var p=n(t.data.getList("mo")),h=p.next();!h.done;h=p.next()){var f=h.value;if(!f.getProperty("relationsCombined")&&f.parent&&(!f.parent||a.default.isType(f.parent,"mrow"))&&a.default.getTexClass(f)===s.TEXCLASS.REL){for(var d=f.parent,m=void 0,y=d.childNodes,g=y.indexOf(f)+1,b=a.default.getProperty(f,"variantForm");g0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.FindTeX=void 0;var s=r(3494),a=r(505),l=r(4474),c=function(t){function e(e){var r=t.call(this,e)||this;return r.getPatterns(),r}return o(e,t),e.prototype.getPatterns=function(){var t=this,e=this.options,r=[],n=[],o=[];this.end={},this.env=this.sub=0;var i=1;e.inlineMath.forEach((function(e){return t.addPattern(r,e,!1)})),e.displayMath.forEach((function(e){return t.addPattern(r,e,!0)})),r.length&&n.push(r.sort(a.sortLength).join("|")),e.processEnvironments&&(n.push("\\\\begin\\s*\\{([^}]*)\\}"),this.env=i,i++),e.processEscapes&&o.push("\\\\([\\\\$])"),e.processRefs&&o.push("(\\\\(?:eq)?ref\\s*\\{[^}]*\\})"),o.length&&(n.push("("+o.join("|")+")"),this.sub=i),this.start=new RegExp(n.join("|"),"g"),this.hasPatterns=n.length>0},e.prototype.addPattern=function(t,e,r){var n=i(e,2),o=n[0],s=n[1];t.push((0,a.quotePattern)(o)),this.end[o]=[s,r,this.endPattern(s)]},e.prototype.endPattern=function(t,e){return new RegExp((e||(0,a.quotePattern)(t))+"|\\\\(?:[a-zA-Z]|.)|[{}]","g")},e.prototype.findEnd=function(t,e,r,n){for(var o,s=i(n,3),a=s[0],c=s[1],u=s[2],p=u.lastIndex=r.index+r[0].length,h=0;o=u.exec(t);){if((o[1]||o[0])===a&&0===h)return(0,l.protoItem)(r[0],t.substr(p,o.index-p),o[0],e,r.index,o.index+o[0].length,c);"{"===o[0]?h++:"}"===o[0]&&h&&h--}return null},e.prototype.findMathInString=function(t,e,r){var n,o;for(this.start.lastIndex=0;n=this.start.exec(r);){if(void 0!==n[this.env]&&this.env){var i="\\\\end\\s*(\\{"+(0,a.quotePattern)(n[this.env])+"\\})";(o=this.findEnd(r,e,n,["{"+n[this.env]+"}",!0,this.endPattern(null,i)]))&&(o.math=o.open+o.math+o.close,o.open=o.close="")}else if(void 0!==n[this.sub]&&this.sub){var s=n[this.sub];i=n.index+n[this.sub].length;o=2===s.length?(0,l.protoItem)("",s.substr(1),"",e,n.index,i):(0,l.protoItem)("",s,"",e,n.index,i,!1)}else o=this.findEnd(r,e,n,this.end[n[0]]);o&&(t.push(o),this.start.lastIndex=o.end.n)}},e.prototype.findMath=function(t){var e=[];if(this.hasPatterns)for(var r=0,n=t.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.SubHandlers=e.SubHandler=e.MapHandler=void 0;var i,s=r(8666),a=r(7525);!function(t){var e=new Map;t.register=function(t){e.set(t.name,t)},t.getMap=function(t){return e.get(t)}}(i=e.MapHandler||(e.MapHandler={}));var l=function(){function t(){this._configuration=new s.PrioritizedList,this._fallback=new a.FunctionList}return t.prototype.add=function(t,e,r){var o,a;void 0===r&&(r=s.PrioritizedList.DEFAULTPRIORITY);try{for(var l=n(t.slice().reverse()),c=l.next();!c.done;c=l.next()){var u=c.value,p=i.getMap(u);if(!p)return void this.warn("Configuration "+u+" not found! Omitted.");this._configuration.add(p,r)}}catch(t){o={error:t}}finally{try{c&&!c.done&&(a=l.return)&&a.call(l)}finally{if(o)throw o.error}}e&&this._fallback.add(e,r)},t.prototype.parse=function(t){var e,r;try{for(var i=n(this._configuration),s=i.next();!s.done;s=i.next()){var a=s.value.item.parse(t);if(a)return a}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}var l=o(t,2),c=l[0],u=l[1];Array.from(this._fallback)[0].item(c,u)},t.prototype.lookup=function(t){var e=this.applicable(t);return e?e.lookup(t):null},t.prototype.contains=function(t){return!!this.applicable(t)},t.prototype.toString=function(){var t,e,r=[];try{for(var o=n(this._configuration),i=o.next();!i.done;i=o.next()){var s=i.value.item;r.push(s.name)}}catch(e){t={error:e}}finally{try{i&&!i.done&&(e=o.return)&&e.call(o)}finally{if(t)throw t.error}}return r.join(", ")},t.prototype.applicable=function(t){var e,r;try{for(var o=n(this._configuration),i=o.next();!i.done;i=o.next()){var s=i.value.item;if(s.contains(t))return s}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}return null},t.prototype.retrieve=function(t){var e,r;try{for(var o=n(this._configuration),i=o.next();!i.done;i=o.next()){var s=i.value.item;if(s.name===t)return s}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}return null},t.prototype.warn=function(t){console.log("TexParser Warning: "+t)},t}();e.SubHandler=l;var c=function(){function t(){this.map=new Map}return t.prototype.add=function(t,e,r){var o,i;void 0===r&&(r=s.PrioritizedList.DEFAULTPRIORITY);try{for(var a=n(Object.keys(t)),c=a.next();!c.done;c=a.next()){var u=c.value,p=this.get(u);p||(p=new l,this.set(u,p)),p.add(t[u],e[u],r)}}catch(t){o={error:t}}finally{try{c&&!c.done&&(i=a.return)&&i.call(a)}finally{if(o)throw o.error}}},t.prototype.set=function(t,e){this.map.set(t,e)},t.prototype.get=function(t){return this.map.get(t)},t.prototype.retrieve=function(t){var e,r;try{for(var o=n(this.map.values()),i=o.next();!i.done;i=o.next()){var s=i.value.retrieve(t);if(s)return s}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}return null},t.prototype.keys=function(){return this.map.keys()},t}();e.SubHandlers=c},8929:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o1&&(r.autoOP=!1));var o=t.create("token","mi",r,e);t.Push(o)},t.digit=function(t,e){var r,n=t.configuration.options.digits,o=t.string.slice(t.i-1).match(n),i=c.default.getFontDef(t);o?(r=t.create("token","mn",i,o[0].replace(/[{}]/g,"")),t.i+=o[0].length-1):r=t.create("token","mo",i,e),t.Push(r)},t.controlSequence=function(t,e){var r=t.GetCS();t.parse("macro",[t,r])},t.mathchar0mi=function(t,e){var r=e.attributes||{mathvariant:l.TexConstant.Variant.ITALIC},n=t.create("token","mi",r,e.char);t.Push(n)},t.mathchar0mo=function(t,e){var r=e.attributes||{};r.stretchy=!1;var n=t.create("token","mo",r,e.char);a.default.setProperty(n,"fixStretchy",!0),t.configuration.addNode("fixStretchy",n),t.Push(n)},t.mathchar7=function(t,e){var r=e.attributes||{mathvariant:l.TexConstant.Variant.NORMAL};t.stack.env.font&&(r.mathvariant=t.stack.env.font);var n=t.create("token","mi",r,e.char);t.Push(n)},t.delimiter=function(t,e){var r=e.attributes||{};r=Object.assign({fence:!1,stretchy:!1},r);var n=t.create("token","mo",r,e.char);t.Push(n)},t.environment=function(t,e,r,i){var s=i[0],a=t.itemFactory.create("begin").setProperties({name:e,end:s});a=r.apply(void 0,o([t,a],n(i.slice(1)),!1)),t.Push(a)}}(s||(s={})),e.default=s},8562:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var a=s(r(5453)),l=r(8929),c=s(r(1256)),u=r(7233),p=function(){function t(t,e){void 0===e&&(e=[]),this.options={},this.packageData=new Map,this.parsers=[],this.root=null,this.nodeLists={},this.error=!1,this.handlers=t.handlers,this.nodeFactory=new l.NodeFactory,this.nodeFactory.configuration=this,this.nodeFactory.setCreators(t.nodes),this.itemFactory=new a.default(t.items),this.itemFactory.configuration=this,u.defaultOptions.apply(void 0,o([this.options],n(e),!1)),(0,u.defaultOptions)(this.options,t.options)}return t.prototype.pushParser=function(t){this.parsers.unshift(t)},t.prototype.popParser=function(){this.parsers.shift()},Object.defineProperty(t.prototype,"parser",{get:function(){return this.parsers[0]},enumerable:!1,configurable:!0}),t.prototype.clear=function(){this.parsers=[],this.root=null,this.nodeLists={},this.error=!1,this.tags.resetTag()},t.prototype.addNode=function(t,e){var r=this.nodeLists[t];if(r||(r=this.nodeLists[t]=[]),r.push(e),e.kind!==t){var n=c.default.getProperty(e,"in-lists")||"",o=(n?n.split(/,/):[]).concat(t).join(",");c.default.setProperty(e,"in-lists",o)}},t.prototype.getList=function(t){var e,r,n=this.nodeLists[t]||[],o=[];try{for(var s=i(n),a=s.next();!a.done;a=s.next()){var l=a.value;this.inTree(l)&&o.push(l)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}return this.nodeLists[t]=o,o},t.prototype.removeFromList=function(t,e){var r,n,o=this.nodeLists[t]||[];try{for(var s=i(e),a=s.next();!a.done;a=s.next()){var l=a.value,c=o.indexOf(l);c>=0&&o.splice(c,1)}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=s.return)&&n.call(s)}finally{if(r)throw r.error}}},t.prototype.inTree=function(t){for(;t&&t!==this.root;)t=t.parent;return!!t},t}();e.default=p},1130:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var s,a=r(9007),l=i(r(1256)),c=i(r(8417)),u=i(r(3971)),p=r(5368);!function(t){var e=7.2,r={em:function(t){return t},ex:function(t){return.43*t},pt:function(t){return t/10},pc:function(t){return 1.2*t},px:function(t){return t*e/72},in:function(t){return t*e},cm:function(t){return t*e/2.54},mm:function(t){return t*e/25.4},mu:function(t){return t/18}},i="([-+]?([.,]\\d+|\\d+([.,]\\d*)?))",s="(pt|em|ex|mu|px|mm|cm|in|pc)",h=RegExp("^\\s*"+i+"\\s*"+s+"\\s*$"),f=RegExp("^\\s*"+i+"\\s*"+s+" ?");function d(t,e){void 0===e&&(e=!1);var o=t.match(e?f:h);return o?function(t){var e=n(t,3),o=e[0],i=e[1],s=e[2];if("mu"!==i)return[o,i,s];return[m(r[i](parseFloat(o||"1"))).slice(0,-2),"em",s]}([o[1].replace(/,/,"."),o[4],o[0].length]):[null,null,0]}function m(t){return Math.abs(t)<6e-4?"0em":t.toFixed(3).replace(/\.?0+$/,"")+"em"}function y(t,e,r){"{"!==e&&"}"!==e||(e="\\"+e);var n="{\\bigg"+r+" "+e+"}",o="{\\big"+r+" "+e+"}";return new c.default("\\mathchoice"+n+o+o+o,{},t).mml()}function g(t,e,r){e=e.replace(/^\s+/,p.entities.nbsp).replace(/\s+$/,p.entities.nbsp);var n=t.create("text",e);return t.create("node","mtext",[],r,n)}function b(t,e,r){if(r.match(/^[a-z]/i)&&e.match(/(^|[^\\])(\\\\)*\\[a-z]+$/i)&&(e+=" "),e.length+r.length>t.configuration.options.maxBuffer)throw new u.default("MaxBufferSize","MathJax internal buffer size exceeded; is there a recursive macro call?");return e+r}function v(t,e){for(;e>0;)t=t.trim().slice(1,-1),e--;return t.trim()}function _(t,e){for(var r=t.length,n=0,o="",i=0,s=0,a=!0,l=!1;in&&(s=n)),n++;break;case"}":n&&n--,(a||l)&&(s--,l=!0),a=!1;break;default:if(!n&&-1!==e.indexOf(c))return[l?"true":v(o,s),c,t.slice(i)];a=!1,l=!1}o+=c}if(n)throw new u.default("ExtraOpenMissingClose","Extra open brace or missing close brace");return[l?"true":v(o,s),"",t.slice(i)]}t.matchDimen=d,t.dimen2em=function(t){var e=n(d(t),2),o=e[0],i=e[1],s=parseFloat(o||"1"),a=r[i];return a?a(s):0},t.Em=m,t.cols=function(){for(var t=[],e=0;e1&&(l=[t.create("node","mrow",l)]),l},t.internalText=g,t.underOver=function(e,r,n,o,i){if(t.checkMovableLimits(r),l.default.isType(r,"munderover")&&l.default.isEmbellished(r)){l.default.setProperties(l.default.getCoreMO(r),{lspace:0,rspace:0});var s=e.create("node","mo",[],{rspace:0});r=e.create("node","mrow",[s,r])}var c=e.create("node","munderover",[r]);l.default.setChild(c,"over"===o?c.over:c.under,n);var u=c;return i&&(u=e.create("node","TeXAtom",[c],{texClass:a.TEXCLASS.OP,movesupsub:!0})),l.default.setProperty(u,"subsupOK",!0),u},t.checkMovableLimits=function(t){var e=l.default.isType(t,"mo")?l.default.getForm(t):null;(l.default.getProperty(t,"movablelimits")||e&&e[3]&&e[3].movablelimits)&&l.default.setProperties(t,{movablelimits:!1})},t.trimSpaces=function(t){if("string"!=typeof t)return t;var e=t.trim();return e.match(/\\$/)&&t.match(/ $/)&&(e+=" "),e},t.setArrayAlign=function(e,r){return"t"===(r=t.trimSpaces(r||""))?e.arraydef.align="baseline 1":"b"===r?e.arraydef.align="baseline -1":"c"===r?e.arraydef.align="axis":r&&(e.arraydef.align=r),e},t.substituteArgs=function(t,e,r){for(var n="",o="",i=0;ie.length)throw new u.default("IllegalMacroParam","Illegal macro parameter reference");o=b(t,b(t,o,n),e[parseInt(s,10)-1]),n=""}else n+=s}return b(t,o,n)},t.addArgs=b,t.checkMaxMacros=function(t,e){if(void 0===e&&(e=!0),!(++t.macroCount<=t.configuration.options.maxMacros))throw e?new u.default("MaxMacroSub1","MathJax maximum macro substitution count exceeded; is here a recursive macro call?"):new u.default("MaxMacroSub2","MathJax maximum substitution count exceeded; is there a recursive latex environment?")},t.checkEqnEnv=function(t){if(t.stack.global.eqnenv)throw new u.default("ErroneousNestingEq","Erroneous nesting of equation structures");t.stack.global.eqnenv=!0},t.copyNode=function(t,e){var r=t.copy(),n=e.configuration;return r.walkTree((function(t){var e,r;n.addNode(t.kind,t);var i=(t.getProperty("in-lists")||"").split(/,/);try{for(var s=o(i),a=s.next();!a.done;a=s.next()){var l=a.value;l&&n.addNode(l,t)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}})),r},t.MmlFilterAttribute=function(t,e,r){return r},t.getFontDef=function(t){var e=t.stack.env.font;return e?{mathvariant:e}:{}},t.keyvalOptions=function(t,e,r){var i,s;void 0===e&&(e=null),void 0===r&&(r=!1);var a=function(t){var e,r,o,i,s,a={},l=t;for(;l;)i=(e=n(_(l,["=",","]),3))[0],o=e[1],l=e[2],"="===o?(s=(r=n(_(l,[","]),3))[0],o=r[1],l=r[2],s="false"===s||"true"===s?JSON.parse(s):s,a[i]=s):i&&(a[i]=!0);return a}(t);if(e)try{for(var l=o(Object.keys(a)),c=l.next();!c.done;c=l.next()){var p=c.value;if(!e.hasOwnProperty(p)){if(r)throw new u.default("InvalidOption","Invalid option: %1",p);delete a[p]}}}catch(t){i={error:t}}finally{try{c&&!c.done&&(s=l.return)&&s.call(l)}finally{if(i)throw i.error}}return a}}(s||(s={})),e.default=s},9497:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},l=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.BaseItem=e.MmlStack=void 0;var c=l(r(3971)),u=function(){function t(t){this._nodes=t}return Object.defineProperty(t.prototype,"nodes",{get:function(){return this._nodes},enumerable:!1,configurable:!0}),t.prototype.Push=function(){for(var t,e=[],r=0;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},a=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.TagsFactory=e.AllTags=e.NoTags=e.AbstractTags=e.TagInfo=e.Label=void 0;var a=s(r(8417)),l=function(t,e){void 0===t&&(t="???"),void 0===e&&(e=""),this.tag=t,this.id=e};e.Label=l;var c=function(t,e,r,n,o,i,s,a){void 0===t&&(t=""),void 0===e&&(e=!1),void 0===r&&(r=!1),void 0===n&&(n=null),void 0===o&&(o=""),void 0===i&&(i=""),void 0===s&&(s=!1),void 0===a&&(a=""),this.env=t,this.taggable=e,this.defaultTags=r,this.tag=n,this.tagId=o,this.tagFormat=i,this.noTag=s,this.labelId=a};e.TagInfo=c;var u=function(){function t(){this.counter=0,this.allCounter=0,this.configuration=null,this.ids={},this.allIds={},this.labels={},this.allLabels={},this.redo=!1,this.refUpdate=!1,this.currentTag=new c,this.history=[],this.stack=[],this.enTag=function(t,e){var r=this.configuration.nodeFactory,n=r.create("node","mtd",[t]),o=r.create("node","mlabeledtr",[e,n]);return r.create("node","mtable",[o],{side:this.configuration.options.tagSide,minlabelspacing:this.configuration.options.tagIndent,displaystyle:!0})}}return t.prototype.start=function(t,e,r){this.currentTag&&this.stack.push(this.currentTag),this.currentTag=new c(t,e,r)},Object.defineProperty(t.prototype,"env",{get:function(){return this.currentTag.env},enumerable:!1,configurable:!0}),t.prototype.end=function(){this.history.push(this.currentTag),this.currentTag=this.stack.pop()},t.prototype.tag=function(t,e){this.currentTag.tag=t,this.currentTag.tagFormat=e?t:this.formatTag(t),this.currentTag.noTag=!1},t.prototype.notag=function(){this.tag("",!0),this.currentTag.noTag=!0},Object.defineProperty(t.prototype,"noTag",{get:function(){return this.currentTag.noTag},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"label",{get:function(){return this.currentTag.labelId},set:function(t){this.currentTag.labelId=t},enumerable:!1,configurable:!0}),t.prototype.formatUrl=function(t,e){return e+"#"+encodeURIComponent(t)},t.prototype.formatTag=function(t){return"("+t+")"},t.prototype.formatId=function(t){return"mjx-eqn:"+t.replace(/\s/g,"_")},t.prototype.formatNumber=function(t){return t.toString()},t.prototype.autoTag=function(){null==this.currentTag.tag&&(this.counter++,this.tag(this.formatNumber(this.counter),!1))},t.prototype.clearTag=function(){this.label="",this.tag(null,!0),this.currentTag.tagId=""},t.prototype.getTag=function(t){if(void 0===t&&(t=!1),t)return this.autoTag(),this.makeTag();var e=this.currentTag;return e.taggable&&!e.noTag&&(e.defaultTags&&this.autoTag(),e.tag)?this.makeTag():null},t.prototype.resetTag=function(){this.history=[],this.redo=!1,this.refUpdate=!1,this.clearTag()},t.prototype.reset=function(t){void 0===t&&(t=0),this.resetTag(),this.counter=this.allCounter=t,this.allLabels={},this.allIds={}},t.prototype.startEquation=function(t){this.history=[],this.stack=[],this.clearTag(),this.currentTag=new c("",void 0,void 0),this.labels={},this.ids={},this.counter=this.allCounter,this.redo=!1;var e=t.inputData.recompile;e&&(this.refUpdate=!0,this.counter=e.counter)},t.prototype.finishEquation=function(t){this.redo&&(t.inputData.recompile={state:t.state(),counter:this.allCounter}),this.refUpdate||(this.allCounter=this.counter),Object.assign(this.allIds,this.ids),Object.assign(this.allLabels,this.labels)},t.prototype.finalize=function(t,e){if(!e.display||this.currentTag.env||null==this.currentTag.tag)return t;var r=this.makeTag();return this.enTag(t,r)},t.prototype.makeId=function(){this.currentTag.tagId=this.formatId(this.configuration.options.useLabelIds&&this.label||this.currentTag.tag)},t.prototype.makeTag=function(){this.makeId(),this.label&&(this.labels[this.label]=new l(this.currentTag.tag,this.currentTag.tagId));var t=new a.default("\\text{"+this.currentTag.tagFormat+"}",{},this.configuration).mml();return this.configuration.nodeFactory.create("node","mtd",[t],{id:this.currentTag.tagId})},t}();e.AbstractTags=u;var p=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.autoTag=function(){},e.prototype.getTag=function(){return this.currentTag.tag?t.prototype.getTag.call(this):null},e}(u);e.NoTags=p;var h=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.finalize=function(t,e){if(!e.display||this.history.find((function(t){return t.taggable})))return t;var r=this.getTag(!0);return this.enTag(t,r)},e}(u);e.AllTags=h,function(t){var e=new Map([["none",p],["all",h]]),r="none";t.OPTIONS={tags:r,tagSide:"right",tagIndent:"0.8em",useLabelIds:!0,ignoreDuplicateLabels:!1},t.add=function(t,r){e.set(t,r)},t.addTags=function(e){var r,n;try{for(var o=i(Object.keys(e)),s=o.next();!s.done;s=o.next()){var a=s.value;t.add(a,e[a])}}catch(t){r={error:t}}finally{try{s&&!s.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}},t.create=function(t){var n=e.get(t)||e.get(r);if(!n)throw Error("Unknown tags class");return new n},t.setDefault=function(t){r=t},t.getDefault=function(){return t.create(r)}}(e.TagsFactory||(e.TagsFactory={}))},8317:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.TexConstant=void 0,function(t){t.Variant={NORMAL:"normal",BOLD:"bold",ITALIC:"italic",BOLDITALIC:"bold-italic",DOUBLESTRUCK:"double-struck",FRAKTUR:"fraktur",BOLDFRAKTUR:"bold-fraktur",SCRIPT:"script",BOLDSCRIPT:"bold-script",SANSSERIF:"sans-serif",BOLDSANSSERIF:"bold-sans-serif",SANSSERIFITALIC:"sans-serif-italic",SANSSERIFBOLDITALIC:"sans-serif-bold-italic",MONOSPACE:"monospace",INITIAL:"inital",TAILED:"tailed",LOOPED:"looped",STRETCHED:"stretched",CALLIGRAPHIC:"-tex-calligraphic",BOLDCALLIGRAPHIC:"-tex-bold-calligraphic",OLDSTYLE:"-tex-oldstyle",BOLDOLDSTYLE:"-tex-bold-oldstyle",MATHITALIC:"-tex-mathit"},t.Form={PREFIX:"prefix",INFIX:"infix",POSTFIX:"postfix"},t.LineBreak={AUTO:"auto",NEWLINE:"newline",NOBREAK:"nobreak",GOODBREAK:"goodbreak",BADBREAK:"badbreak"},t.LineBreakStyle={BEFORE:"before",AFTER:"after",DUPLICATE:"duplicate",INFIXLINBREAKSTYLE:"infixlinebreakstyle"},t.IndentAlign={LEFT:"left",CENTER:"center",RIGHT:"right",AUTO:"auto",ID:"id",INDENTALIGN:"indentalign"},t.IndentShift={INDENTSHIFT:"indentshift"},t.LineThickness={THIN:"thin",MEDIUM:"medium",THICK:"thick"},t.Notation={LONGDIV:"longdiv",ACTUARIAL:"actuarial",PHASORANGLE:"phasorangle",RADICAL:"radical",BOX:"box",ROUNDEDBOX:"roundedbox",CIRCLE:"circle",LEFT:"left",RIGHT:"right",TOP:"top",BOTTOM:"bottom",UPDIAGONALSTRIKE:"updiagonalstrike",DOWNDIAGONALSTRIKE:"downdiagonalstrike",VERTICALSTRIKE:"verticalstrike",HORIZONTALSTRIKE:"horizontalstrike",NORTHEASTARROW:"northeastarrow",MADRUWB:"madruwb",UPDIAGONALARROW:"updiagonalarrow"},t.Align={TOP:"top",BOTTOM:"bottom",CENTER:"center",BASELINE:"baseline",AXIS:"axis",LEFT:"left",RIGHT:"right"},t.Lines={NONE:"none",SOLID:"solid",DASHED:"dashed"},t.Side={LEFT:"left",RIGHT:"right",LEFTOVERLAP:"leftoverlap",RIGHTOVERLAP:"rightoverlap"},t.Width={AUTO:"auto",FIT:"fit"},t.Actiontype={TOGGLE:"toggle",STATUSLINE:"statusline",TOOLTIP:"tooltip",INPUT:"input"},t.Overflow={LINBREAK:"linebreak",SCROLL:"scroll",ELIDE:"elide",TRUNCATE:"truncate",SCALE:"scale"},t.Unit={EM:"em",EX:"ex",PX:"px",IN:"in",CM:"cm",MM:"mm",PT:"pt",PC:"pc"}}(e.TexConstant||(e.TexConstant={}))},3971:function(t,e){Object.defineProperty(e,"__esModule",{value:!0});var r=function(){function t(e,r){for(var n=[],o=2;o="0"&&s<="9")n[o]=r[parseInt(n[o],10)-1],"number"==typeof n[o]&&(n[o]=n[o].toString());else if("{"===s){if((s=n[o].substr(1))>="0"&&s<="9")n[o]=r[parseInt(n[o].substr(1,n[o].length-2),10)-1],"number"==typeof n[o]&&(n[o]=n[o].toString());else n[o].match(/^\{([a-z]+):%(\d+)\|(.*)\}$/)&&(n[o]="%"+n[o])}null==n[o]&&(n[o]="???")}return n.join("")},t.pattern=/%(\d+|\{\d+\}|\{[a-z]+:\%\d+(?:\|(?:%\{\d+\}|%.|[^\}])*)+\}|.)/g,t}();e.default=r},8417:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;oe)throw new u.default("XalignOverflow","Extra %1 in row of %2","&",this.name)},e.prototype.EndRow=function(){for(var e,r=this.row,n=this.getProperty("xalignat");r.lengththis.maxrow&&(this.maxrow=this.row.length),t.prototype.EndRow.call(this);var o=this.table[this.table.length-1];if(this.getProperty("zeroWidthLabel")&&o.isKind("mlabeledtr")){var s=c.default.getChildren(o)[0],a=this.factory.configuration.options.tagSide,l=i({width:0},"right"===a?{lspace:"-1width"}:{}),u=this.create("node","mpadded",c.default.getChildren(s),l);s.setChildren([u])}},e.prototype.EndTable=function(){(t.prototype.EndTable.call(this),this.center)&&(this.maxrow<=2&&(delete this.arraydef.width,delete this.global.indentalign))},e}(a.EqnArrayItem);e.FlalignItem=f},7379:function(t,e,r){var n=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),o=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),i=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&n(e,t,r);return o(e,t),e},s=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var a=r(4387),l=i(r(9140)),c=r(8317),u=s(r(5450)),p=s(r(1130)),h=r(9007),f=r(6010);new l.CharacterMap("AMSmath-mathchar0mo",u.default.mathchar0mo,{iiiint:["\u2a0c",{texClass:h.TEXCLASS.OP}]}),new l.RegExpMap("AMSmath-operatorLetter",a.AmsMethods.operatorLetter,/[-*]/i),new l.CommandMap("AMSmath-macros",{mathring:["Accent","02DA"],nobreakspace:"Tilde",negmedspace:["Spacer",f.MATHSPACE.negativemediummathspace],negthickspace:["Spacer",f.MATHSPACE.negativethickmathspace],idotsint:["MultiIntegral","\\int\\cdots\\int"],dddot:["Accent","20DB"],ddddot:["Accent","20DC"],sideset:"SideSet",boxed:["Macro","\\fbox{$\\displaystyle{#1}$}",1],tag:"HandleTag",notag:"HandleNoTag",eqref:["HandleRef",!0],substack:["Macro","\\begin{subarray}{c}#1\\end{subarray}",1],injlim:["NamedOp","inj lim"],projlim:["NamedOp","proj lim"],varliminf:["Macro","\\mathop{\\underline{\\mmlToken{mi}{lim}}}"],varlimsup:["Macro","\\mathop{\\overline{\\mmlToken{mi}{lim}}}"],varinjlim:["Macro","\\mathop{\\underrightarrow{\\mmlToken{mi}{lim}}}"],varprojlim:["Macro","\\mathop{\\underleftarrow{\\mmlToken{mi}{lim}}}"],DeclareMathOperator:"HandleDeclareOp",operatorname:"HandleOperatorName",genfrac:"Genfrac",frac:["Genfrac","","","",""],tfrac:["Genfrac","","","","1"],dfrac:["Genfrac","","","","0"],binom:["Genfrac","(",")","0",""],tbinom:["Genfrac","(",")","0","1"],dbinom:["Genfrac","(",")","0","0"],cfrac:"CFrac",shoveleft:["HandleShove",c.TexConstant.Align.LEFT],shoveright:["HandleShove",c.TexConstant.Align.RIGHT],xrightarrow:["xArrow",8594,5,10],xleftarrow:["xArrow",8592,10,5]},a.AmsMethods),new l.EnvironmentMap("AMSmath-environment",u.default.environment,{"equation*":["Equation",null,!1],"eqnarray*":["EqnArray",null,!1,!0,"rcl",p.default.cols(0,f.MATHSPACE.thickmathspace),".5em"],align:["EqnArray",null,!0,!0,"rl",p.default.cols(0,2)],"align*":["EqnArray",null,!1,!0,"rl",p.default.cols(0,2)],multline:["Multline",null,!0],"multline*":["Multline",null,!1],split:["EqnArray",null,!1,!1,"rl",p.default.cols(0)],gather:["EqnArray",null,!0,!0,"c"],"gather*":["EqnArray",null,!1,!0,"c"],alignat:["AlignAt",null,!0,!0],"alignat*":["AlignAt",null,!1,!0],alignedat:["AlignAt",null,!1,!1],aligned:["AmsEqnArray",null,null,null,"rl",p.default.cols(0,2),".5em","D"],gathered:["AmsEqnArray",null,null,null,"c",null,".5em","D"],xalignat:["XalignAt",null,!0,!0],"xalignat*":["XalignAt",null,!1,!0],xxalignat:["XalignAt",null,!1,!1],flalign:["FlalignArray",null,!0,!1,!0,"rlc","auto auto fit"],"flalign*":["FlalignArray",null,!1,!1,!0,"rlc","auto auto fit"],subarray:["Array",null,null,null,null,p.default.cols(0),"0.1em","S",1],smallmatrix:["Array",null,null,null,"c",p.default.cols(1/3),".2em","S",1],matrix:["Array",null,null,null,"c"],pmatrix:["Array",null,"(",")","c"],bmatrix:["Array",null,"[","]","c"],Bmatrix:["Array",null,"\\{","\\}","c"],vmatrix:["Array",null,"\\vert","\\vert","c"],Vmatrix:["Array",null,"\\Vert","\\Vert","c"],cases:["Array",null,"\\{",".","ll",null,".2em","T"]},a.AmsMethods),new l.DelimiterMap("AMSmath-delimiter",u.default.delimiter,{"\\lvert":["|",{texClass:h.TEXCLASS.OPEN}],"\\rvert":["|",{texClass:h.TEXCLASS.CLOSE}],"\\lVert":["\u2016",{texClass:h.TEXCLASS.OPEN}],"\\rVert":["\u2016",{texClass:h.TEXCLASS.CLOSE}]}),new l.CharacterMap("AMSsymbols-mathchar0mi",u.default.mathchar0mi,{digamma:"\u03dd",varkappa:"\u03f0",varGamma:["\u0393",{mathvariant:c.TexConstant.Variant.ITALIC}],varDelta:["\u0394",{mathvariant:c.TexConstant.Variant.ITALIC}],varTheta:["\u0398",{mathvariant:c.TexConstant.Variant.ITALIC}],varLambda:["\u039b",{mathvariant:c.TexConstant.Variant.ITALIC}],varXi:["\u039e",{mathvariant:c.TexConstant.Variant.ITALIC}],varPi:["\u03a0",{mathvariant:c.TexConstant.Variant.ITALIC}],varSigma:["\u03a3",{mathvariant:c.TexConstant.Variant.ITALIC}],varUpsilon:["\u03a5",{mathvariant:c.TexConstant.Variant.ITALIC}],varPhi:["\u03a6",{mathvariant:c.TexConstant.Variant.ITALIC}],varPsi:["\u03a8",{mathvariant:c.TexConstant.Variant.ITALIC}],varOmega:["\u03a9",{mathvariant:c.TexConstant.Variant.ITALIC}],beth:"\u2136",gimel:"\u2137",daleth:"\u2138",backprime:["\u2035",{variantForm:!0}],hslash:"\u210f",varnothing:["\u2205",{variantForm:!0}],blacktriangle:"\u25b4",triangledown:["\u25bd",{variantForm:!0}],blacktriangledown:"\u25be",square:"\u25fb",Box:"\u25fb",blacksquare:"\u25fc",lozenge:"\u25ca",Diamond:"\u25ca",blacklozenge:"\u29eb",circledS:["\u24c8",{mathvariant:c.TexConstant.Variant.NORMAL}],bigstar:"\u2605",sphericalangle:"\u2222",measuredangle:"\u2221",nexists:"\u2204",complement:"\u2201",mho:"\u2127",eth:["\xf0",{mathvariant:c.TexConstant.Variant.NORMAL}],Finv:"\u2132",diagup:"\u2571",Game:"\u2141",diagdown:"\u2572",Bbbk:["k",{mathvariant:c.TexConstant.Variant.DOUBLESTRUCK}],yen:"\xa5",circledR:"\xae",checkmark:"\u2713",maltese:"\u2720"}),new l.CharacterMap("AMSsymbols-mathchar0mo",u.default.mathchar0mo,{dotplus:"\u2214",ltimes:"\u22c9",smallsetminus:["\u2216",{variantForm:!0}],rtimes:"\u22ca",Cap:"\u22d2",doublecap:"\u22d2",leftthreetimes:"\u22cb",Cup:"\u22d3",doublecup:"\u22d3",rightthreetimes:"\u22cc",barwedge:"\u22bc",curlywedge:"\u22cf",veebar:"\u22bb",curlyvee:"\u22ce",doublebarwedge:"\u2a5e",boxminus:"\u229f",circleddash:"\u229d",boxtimes:"\u22a0",circledast:"\u229b",boxdot:"\u22a1",circledcirc:"\u229a",boxplus:"\u229e",centerdot:["\u22c5",{variantForm:!0}],divideontimes:"\u22c7",intercal:"\u22ba",leqq:"\u2266",geqq:"\u2267",leqslant:"\u2a7d",geqslant:"\u2a7e",eqslantless:"\u2a95",eqslantgtr:"\u2a96",lesssim:"\u2272",gtrsim:"\u2273",lessapprox:"\u2a85",gtrapprox:"\u2a86",approxeq:"\u224a",lessdot:"\u22d6",gtrdot:"\u22d7",lll:"\u22d8",llless:"\u22d8",ggg:"\u22d9",gggtr:"\u22d9",lessgtr:"\u2276",gtrless:"\u2277",lesseqgtr:"\u22da",gtreqless:"\u22db",lesseqqgtr:"\u2a8b",gtreqqless:"\u2a8c",doteqdot:"\u2251",Doteq:"\u2251",eqcirc:"\u2256",risingdotseq:"\u2253",circeq:"\u2257",fallingdotseq:"\u2252",triangleq:"\u225c",backsim:"\u223d",thicksim:["\u223c",{variantForm:!0}],backsimeq:"\u22cd",thickapprox:["\u2248",{variantForm:!0}],subseteqq:"\u2ac5",supseteqq:"\u2ac6",Subset:"\u22d0",Supset:"\u22d1",sqsubset:"\u228f",sqsupset:"\u2290",preccurlyeq:"\u227c",succcurlyeq:"\u227d",curlyeqprec:"\u22de",curlyeqsucc:"\u22df",precsim:"\u227e",succsim:"\u227f",precapprox:"\u2ab7",succapprox:"\u2ab8",vartriangleleft:"\u22b2",lhd:"\u22b2",vartriangleright:"\u22b3",rhd:"\u22b3",trianglelefteq:"\u22b4",unlhd:"\u22b4",trianglerighteq:"\u22b5",unrhd:"\u22b5",vDash:["\u22a8",{variantForm:!0}],Vdash:"\u22a9",Vvdash:"\u22aa",smallsmile:["\u2323",{variantForm:!0}],shortmid:["\u2223",{variantForm:!0}],smallfrown:["\u2322",{variantForm:!0}],shortparallel:["\u2225",{variantForm:!0}],bumpeq:"\u224f",between:"\u226c",Bumpeq:"\u224e",pitchfork:"\u22d4",varpropto:["\u221d",{variantForm:!0}],backepsilon:"\u220d",blacktriangleleft:"\u25c2",blacktriangleright:"\u25b8",therefore:"\u2234",because:"\u2235",eqsim:"\u2242",vartriangle:["\u25b3",{variantForm:!0}],Join:"\u22c8",nless:"\u226e",ngtr:"\u226f",nleq:"\u2270",ngeq:"\u2271",nleqslant:["\u2a87",{variantForm:!0}],ngeqslant:["\u2a88",{variantForm:!0}],nleqq:["\u2270",{variantForm:!0}],ngeqq:["\u2271",{variantForm:!0}],lneq:"\u2a87",gneq:"\u2a88",lneqq:"\u2268",gneqq:"\u2269",lvertneqq:["\u2268",{variantForm:!0}],gvertneqq:["\u2269",{variantForm:!0}],lnsim:"\u22e6",gnsim:"\u22e7",lnapprox:"\u2a89",gnapprox:"\u2a8a",nprec:"\u2280",nsucc:"\u2281",npreceq:["\u22e0",{variantForm:!0}],nsucceq:["\u22e1",{variantForm:!0}],precneqq:"\u2ab5",succneqq:"\u2ab6",precnsim:"\u22e8",succnsim:"\u22e9",precnapprox:"\u2ab9",succnapprox:"\u2aba",nsim:"\u2241",ncong:"\u2247",nshortmid:["\u2224",{variantForm:!0}],nshortparallel:["\u2226",{variantForm:!0}],nmid:"\u2224",nparallel:"\u2226",nvdash:"\u22ac",nvDash:"\u22ad",nVdash:"\u22ae",nVDash:"\u22af",ntriangleleft:"\u22ea",ntriangleright:"\u22eb",ntrianglelefteq:"\u22ec",ntrianglerighteq:"\u22ed",nsubseteq:"\u2288",nsupseteq:"\u2289",nsubseteqq:["\u2288",{variantForm:!0}],nsupseteqq:["\u2289",{variantForm:!0}],subsetneq:"\u228a",supsetneq:"\u228b",varsubsetneq:["\u228a",{variantForm:!0}],varsupsetneq:["\u228b",{variantForm:!0}],subsetneqq:"\u2acb",supsetneqq:"\u2acc",varsubsetneqq:["\u2acb",{variantForm:!0}],varsupsetneqq:["\u2acc",{variantForm:!0}],leftleftarrows:"\u21c7",rightrightarrows:"\u21c9",leftrightarrows:"\u21c6",rightleftarrows:"\u21c4",Lleftarrow:"\u21da",Rrightarrow:"\u21db",twoheadleftarrow:"\u219e",twoheadrightarrow:"\u21a0",leftarrowtail:"\u21a2",rightarrowtail:"\u21a3",looparrowleft:"\u21ab",looparrowright:"\u21ac",leftrightharpoons:"\u21cb",rightleftharpoons:["\u21cc",{variantForm:!0}],curvearrowleft:"\u21b6",curvearrowright:"\u21b7",circlearrowleft:"\u21ba",circlearrowright:"\u21bb",Lsh:"\u21b0",Rsh:"\u21b1",upuparrows:"\u21c8",downdownarrows:"\u21ca",upharpoonleft:"\u21bf",upharpoonright:"\u21be",downharpoonleft:"\u21c3",restriction:"\u21be",multimap:"\u22b8",downharpoonright:"\u21c2",leftrightsquigarrow:"\u21ad",rightsquigarrow:"\u21dd",leadsto:"\u21dd",dashrightarrow:"\u21e2",dashleftarrow:"\u21e0",nleftarrow:"\u219a",nrightarrow:"\u219b",nLeftarrow:"\u21cd",nRightarrow:"\u21cf",nleftrightarrow:"\u21ae",nLeftrightarrow:"\u21ce"}),new l.DelimiterMap("AMSsymbols-delimiter",u.default.delimiter,{"\\ulcorner":"\u231c","\\urcorner":"\u231d","\\llcorner":"\u231e","\\lrcorner":"\u231f"}),new l.CommandMap("AMSsymbols-macros",{implies:["Macro","\\;\\Longrightarrow\\;"],impliedby:["Macro","\\;\\Longleftarrow\\;"]},a.AmsMethods)},4387:function(t,e,r){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.NEW_OPS=e.AmsMethods=void 0;var s=i(r(1130)),a=i(r(5450)),l=i(r(1256)),c=r(8317),u=i(r(8417)),p=i(r(3971)),h=r(8803),f=i(r(7693)),d=r(9007);function m(t){if(!t||t.isInferred&&0===t.childNodes.length)return[null,null];if(t.isKind("msubsup")&&y(t))return[t,null];var e=l.default.getChildAt(t,0);return t.isInferred&&e&&y(e)?(t.childNodes.splice(0,1),[e,t]):[null,t]}function y(t){var e=t.childNodes[0];return e&&e.isKind("mi")&&""===e.getText()}e.AmsMethods={},e.AmsMethods.AmsEqnArray=function(t,e,r,n,o,i,a){var l=t.GetBrackets("\\begin{"+e.getName()+"}"),c=f.default.EqnArray(t,e,r,n,o,i,a);return s.default.setArrayAlign(c,l)},e.AmsMethods.AlignAt=function(t,r,n,o){var i,a,l=r.getName(),c="",u=[];if(o||(a=t.GetBrackets("\\begin{"+l+"}")),(i=t.GetArgument("\\begin{"+l+"}")).match(/[^0-9]/))throw new p.default("PositiveIntegerArg","Argument to %1 must me a positive integer","\\begin{"+l+"}");for(var h=parseInt(i,10);h>0;)c+="rl",u.push("0em 0em"),h--;var f=u.join(" ");if(o)return e.AmsMethods.EqnArray(t,r,n,o,c,f);var d=e.AmsMethods.EqnArray(t,r,n,o,c,f);return s.default.setArrayAlign(d,a)},e.AmsMethods.Multline=function(t,e,r){t.Push(e),s.default.checkEqnEnv(t);var n=t.itemFactory.create("multline",r,t.stack);return n.arraydef={displaystyle:!0,rowspacing:".5em",columnspacing:"100%",width:t.options.ams.multlineWidth,side:t.options.tagSide,minlabelspacing:t.options.tagIndent,framespacing:t.options.ams.multlineIndent+" 0",frame:"","data-width-includes-label":!0},n},e.AmsMethods.XalignAt=function(t,r,n,o){var i=t.GetArgument("\\begin{"+r.getName()+"}");if(i.match(/[^0-9]/))throw new p.default("PositiveIntegerArg","Argument to %1 must me a positive integer","\\begin{"+r.getName()+"}");var s=o?"crl":"rlc",a=o?"fit auto auto":"auto auto fit",l=e.AmsMethods.FlalignArray(t,r,n,o,!1,s,a,!0);return l.setProperty("xalignat",2*parseInt(i)),l},e.AmsMethods.FlalignArray=function(t,e,r,n,o,i,a,l){void 0===l&&(l=!1),t.Push(e),s.default.checkEqnEnv(t),i=i.split("").join(" ").replace(/r/g,"right").replace(/l/g,"left").replace(/c/g,"center");var c=t.itemFactory.create("flalign",e.getName(),r,n,o,t.stack);return c.arraydef={width:"100%",displaystyle:!0,columnalign:i,columnspacing:"0em",columnwidth:a,rowspacing:"3pt",side:t.options.tagSide,minlabelspacing:l?"0":t.options.tagIndent,"data-width-includes-label":!0},c.setProperty("zeroWidthLabel",l),c},e.NEW_OPS="ams-declare-ops",e.AmsMethods.HandleDeclareOp=function(t,r){var n=t.GetStar()?"*":"",o=s.default.trimSpaces(t.GetArgument(r));"\\"===o.charAt(0)&&(o=o.substr(1));var i=t.GetArgument(r);t.configuration.handlers.retrieve(e.NEW_OPS).add(o,new h.Macro(o,e.AmsMethods.Macro,["\\operatorname".concat(n,"{").concat(i,"}")]))},e.AmsMethods.HandleOperatorName=function(t,e){var r=t.GetStar(),o=s.default.trimSpaces(t.GetArgument(e)),i=new u.default(o,n(n({},t.stack.env),{font:c.TexConstant.Variant.NORMAL,multiLetterIdentifiers:/^[-*a-z]+/i,operatorLetters:!0}),t.configuration).mml();if(i.isKind("mi")||(i=t.create("node","TeXAtom",[i])),l.default.setProperties(i,{movesupsub:r,movablelimits:!0,texClass:d.TEXCLASS.OP}),!r){var a=t.GetNext(),p=t.i;"\\"===a&&++t.i&&"limits"!==t.GetCS()&&(t.i=p)}t.Push(i)},e.AmsMethods.SideSet=function(t,e){var r=o(m(t.ParseArg(e)),2),n=r[0],i=r[1],a=o(m(t.ParseArg(e)),2),c=a[0],u=a[1],p=t.ParseArg(e),h=p;n&&(i?n.replaceChild(t.create("node","mphantom",[t.create("node","mpadded",[s.default.copyNode(p,t)],{width:0})]),l.default.getChildAt(n,0)):(h=t.create("node","mmultiscripts",[p]),c&&l.default.appendChildren(h,[l.default.getChildAt(c,1)||t.create("node","none"),l.default.getChildAt(c,2)||t.create("node","none")]),l.default.setProperty(h,"scriptalign","left"),l.default.appendChildren(h,[t.create("node","mprescripts"),l.default.getChildAt(n,1)||t.create("node","none"),l.default.getChildAt(n,2)||t.create("node","none")]))),c&&h===p&&(c.replaceChild(p,l.default.getChildAt(c,0)),h=c);var f=t.create("node","TeXAtom",[],{texClass:d.TEXCLASS.OP,movesupsub:!0,movablelimits:!0});i&&(n&&f.appendChild(n),f.appendChild(i)),f.appendChild(h),u&&f.appendChild(u),t.Push(f)},e.AmsMethods.operatorLetter=function(t,e){return!!t.stack.env.operatorLetters&&a.default.variable(t,e)},e.AmsMethods.MultiIntegral=function(t,e,r){var n=t.GetNext();if("\\"===n){var o=t.i;n=t.GetArgument(e),t.i=o,"\\limits"===n&&(r="\\idotsint"===e?"\\!\\!\\mathop{\\,\\,"+r+"}":"\\!\\!\\!\\mathop{\\,\\,\\,"+r+"}")}t.string=r+" "+t.string.slice(t.i),t.i=0},e.AmsMethods.xArrow=function(t,e,r,n,o){var i={width:"+"+s.default.Em((n+o)/18),lspace:s.default.Em(n/18)},a=t.GetBrackets(e),c=t.ParseArg(e),p=t.create("node","mspace",[],{depth:".25em"}),h=t.create("token","mo",{stretchy:!0,texClass:d.TEXCLASS.REL},String.fromCodePoint(r));h=t.create("node","mstyle",[h],{scriptlevel:0});var f=t.create("node","munderover",[h]),m=t.create("node","mpadded",[c,p],i);if(l.default.setAttribute(m,"voffset","-.2em"),l.default.setAttribute(m,"height","-.2em"),l.default.setChild(f,f.over,m),a){var y=new u.default(a,t.stack.env,t.configuration).mml(),g=t.create("node","mspace",[],{height:".75em"});m=t.create("node","mpadded",[y,g],i),l.default.setAttribute(m,"voffset",".15em"),l.default.setAttribute(m,"depth","-.15em"),l.default.setChild(f,f.under,m)}l.default.setProperty(f,"subsupOK",!0),t.Push(f)},e.AmsMethods.HandleShove=function(t,e,r){var n=t.stack.Top();if("multline"!==n.kind)throw new p.default("CommandOnlyAllowedInEnv","%1 only allowed in %2 environment",t.currentCS,"multline");if(n.Size())throw new p.default("CommandAtTheBeginingOfLine","%1 must come at the beginning of the line",t.currentCS);n.setProperty("shove",r)},e.AmsMethods.CFrac=function(t,e){var r=s.default.trimSpaces(t.GetBrackets(e,"")),n=t.GetArgument(e),o=t.GetArgument(e),i={l:c.TexConstant.Align.LEFT,r:c.TexConstant.Align.RIGHT,"":""},a=new u.default("\\strut\\textstyle{"+n+"}",t.stack.env,t.configuration).mml(),h=new u.default("\\strut\\textstyle{"+o+"}",t.stack.env,t.configuration).mml(),f=t.create("node","mfrac",[a,h]);if(null==(r=i[r]))throw new p.default("IllegalAlign","Illegal alignment specified in %1",t.currentCS);r&&l.default.setProperties(f,{numalign:r,denomalign:r}),t.Push(f)},e.AmsMethods.Genfrac=function(t,e,r,n,o,i){null==r&&(r=t.GetDelimiterArg(e)),null==n&&(n=t.GetDelimiterArg(e)),null==o&&(o=t.GetArgument(e)),null==i&&(i=s.default.trimSpaces(t.GetArgument(e)));var a=t.ParseArg(e),c=t.ParseArg(e),u=t.create("node","mfrac",[a,c]);if(""!==o&&l.default.setAttribute(u,"linethickness",o),(r||n)&&(l.default.setProperty(u,"withDelims",!0),u=s.default.fixedFence(t.configuration,r,u,n)),""!==i){var h=parseInt(i,10),f=["D","T","S","SS"][h];if(null==f)throw new p.default("BadMathStyleFor","Bad math style for %1",t.currentCS);u=t.create("node","mstyle",[u]),"D"===f?l.default.setProperties(u,{displaystyle:!0,scriptlevel:0}):l.default.setProperties(u,{displaystyle:!1,scriptlevel:h-1})}t.Push(u)},e.AmsMethods.HandleTag=function(t,e){if(!t.tags.currentTag.taggable&&t.tags.env)throw new p.default("CommandNotAllowedInEnv","%1 not allowed in %2 environment",t.currentCS,t.tags.env);if(t.tags.currentTag.tag)throw new p.default("MultipleCommand","Multiple %1",t.currentCS);var r=t.GetStar(),n=s.default.trimSpaces(t.GetArgument(e));t.tags.tag(n,r)},e.AmsMethods.HandleNoTag=f.default.HandleNoTag,e.AmsMethods.HandleRef=f.default.HandleRef,e.AmsMethods.Macro=f.default.Macro,e.AmsMethods.Accent=f.default.Accent,e.AmsMethods.Tilde=f.default.Tilde,e.AmsMethods.Array=f.default.Array,e.AmsMethods.Spacer=f.default.Spacer,e.AmsMethods.NamedOp=f.default.NamedOp,e.AmsMethods.EqnArray=f.default.EqnArray,e.AmsMethods.Equation=f.default.Equation},1275:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.AutoloadConfiguration=void 0;var i=r(9899),s=r(9140),a=r(8803),l=r(7741),c=r(265),u=r(7233);function p(t,e,r,i){var s,a,u,p;if(c.Package.packages.has(t.options.require.prefix+r)){var d=t.options.autoload[r],m=n(2===d.length&&Array.isArray(d[0])?d:[d,[]],2),y=m[0],g=m[1];try{for(var b=o(y),v=b.next();!v.done;v=b.next()){var _=v.value;h.remove(_)}}catch(t){s={error:t}}finally{try{v&&!v.done&&(a=b.return)&&a.call(b)}finally{if(s)throw s.error}}try{for(var S=o(g),M=S.next();!M.done;M=S.next()){var O=M.value;f.remove(O)}}catch(t){u={error:t}}finally{try{M&&!M.done&&(p=S.return)&&p.call(S)}finally{if(u)throw u.error}}t.string=(i?e+" ":"\\begin{"+e.slice(1)+"}")+t.string.slice(t.i),t.i=0}(0,l.RequireLoad)(t,r)}var h=new s.CommandMap("autoload-macros",{},{}),f=new s.CommandMap("autoload-environments",{},{});e.AutoloadConfiguration=i.Configuration.create("autoload",{handler:{macro:["autoload-macros"],environment:["autoload-environments"]},options:{autoload:(0,u.expandable)({action:["toggle","mathtip","texttip"],amscd:[[],["CD"]],bbox:["bbox"],boldsymbol:["boldsymbol"],braket:["bra","ket","braket","set","Bra","Ket","Braket","Set","ketbra","Ketbra"],bussproofs:[[],["prooftree"]],cancel:["cancel","bcancel","xcancel","cancelto"],color:["color","definecolor","textcolor","colorbox","fcolorbox"],enclose:["enclose"],extpfeil:["xtwoheadrightarrow","xtwoheadleftarrow","xmapsto","xlongequal","xtofrom","Newextarrow"],html:["href","class","style","cssId"],mhchem:["ce","pu"],newcommand:["newcommand","renewcommand","newenvironment","renewenvironment","def","let"],unicode:["unicode"],verb:["verb"]})},config:function(t,e){var r,i,s,c,u,d,m=e.parseOptions,y=m.handlers.get("macro"),g=m.handlers.get("environment"),b=m.options.autoload;m.packageData.set("autoload",{Autoload:p});try{for(var v=o(Object.keys(b)),_=v.next();!_.done;_=v.next()){var S=_.value,M=b[S],O=n(2===M.length&&Array.isArray(M[0])?M:[M,[]],2),x=O[0],E=O[1];try{for(var A=(s=void 0,o(x)),C=A.next();!C.done;C=A.next()){var T=C.value;y.lookup(T)&&"color"!==T||h.add(T,new a.Macro(T,p,[S,!0]))}}catch(t){s={error:t}}finally{try{C&&!C.done&&(c=A.return)&&c.call(A)}finally{if(s)throw s.error}}try{for(var N=(u=void 0,o(E)),w=N.next();!w.done;w=N.next()){var L=w.value;g.lookup(L)||f.add(L,new a.Macro(L,p,[S,!1]))}}catch(t){u={error:t}}finally{try{w&&!w.done&&(d=N.return)&&d.call(N)}finally{if(u)throw u.error}}}}catch(t){r={error:t}}finally{try{_&&!_.done&&(i=v.return)&&i.call(v)}finally{if(r)throw r.error}}m.packageData.get("require")||l.RequireConfiguration.config(t,e)},init:function(t){t.options.require||(0,u.defaultOptions)(t.options,l.RequireConfiguration.options)},priority:10})},2942:function(t,e,r){var n,o,i=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),s=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),a=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),l=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&s(e,t,r);return a(e,t),e},c=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},u=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.BaseConfiguration=e.BaseTags=e.Other=void 0;var p=r(9899),h=r(2947),f=u(r(3971)),d=u(r(1256)),m=r(9140),y=l(r(1181)),g=r(6521);r(1267);var b=r(4082);function v(t,e){var r=t.stack.env.font?{mathvariant:t.stack.env.font}:{},n=h.MapHandler.getMap("remap").lookup(e),o=(0,b.getRange)(e),i=o?o[3]:"mo",s=t.create("token",i,r,n?n.char:e);o[4]&&s.attributes.set("mathvariant",o[4]),"mo"===i&&(d.default.setProperty(s,"fixStretchy",!0),t.configuration.addNode("fixStretchy",s)),t.Push(s)}new m.CharacterMap("remap",null,{"-":"\u2212","*":"\u2217","`":"\u2018"}),e.Other=v;var _=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return i(e,t),e}(g.AbstractTags);e.BaseTags=_,e.BaseConfiguration=p.Configuration.create("base",{handler:{character:["command","special","letter","digit"],delimiter:["delimiter"],macro:["delimiter","macros","mathchar0mi","mathchar0mo","mathchar7"],environment:["environment"]},fallback:{character:v,macro:function(t,e){throw new f.default("UndefinedControlSequence","Undefined control sequence %1","\\"+e)},environment:function(t,e){throw new f.default("UnknownEnv","Unknown environment '%1'",e)}},items:(o={},o[y.StartItem.prototype.kind]=y.StartItem,o[y.StopItem.prototype.kind]=y.StopItem,o[y.OpenItem.prototype.kind]=y.OpenItem,o[y.CloseItem.prototype.kind]=y.CloseItem,o[y.PrimeItem.prototype.kind]=y.PrimeItem,o[y.SubsupItem.prototype.kind]=y.SubsupItem,o[y.OverItem.prototype.kind]=y.OverItem,o[y.LeftItem.prototype.kind]=y.LeftItem,o[y.Middle.prototype.kind]=y.Middle,o[y.RightItem.prototype.kind]=y.RightItem,o[y.BeginItem.prototype.kind]=y.BeginItem,o[y.EndItem.prototype.kind]=y.EndItem,o[y.StyleItem.prototype.kind]=y.StyleItem,o[y.PositionItem.prototype.kind]=y.PositionItem,o[y.CellItem.prototype.kind]=y.CellItem,o[y.MmlItem.prototype.kind]=y.MmlItem,o[y.FnItem.prototype.kind]=y.FnItem,o[y.NotItem.prototype.kind]=y.NotItem,o[y.NonscriptItem.prototype.kind]=y.NonscriptItem,o[y.DotsItem.prototype.kind]=y.DotsItem,o[y.ArrayItem.prototype.kind]=y.ArrayItem,o[y.EqnArrayItem.prototype.kind]=y.EqnArrayItem,o[y.EquationItem.prototype.kind]=y.EquationItem,o),options:{maxMacros:1e3,baseURL:"undefined"==typeof document||0===document.getElementsByTagName("base").length?"":String(document.location).replace(/#.*$/,"")},tags:{base:_},postprocessors:[[function(t){var e,r,n=t.data;try{for(var o=c(n.getList("nonscript")),i=o.next();!i.done;i=o.next()){var s=i.value;if(s.attributes.get("scriptlevel")>0){var a=s.parent;if(a.childNodes.splice(a.childIndex(s),1),n.removeFromList(s.kind,[s]),s.isKind("mrow")){var l=s.childNodes[0];n.removeFromList("mstyle",[l]),n.removeFromList("mspace",l.childNodes[0].childNodes)}}else s.isKind("mrow")&&(s.parent.replaceChild(s.childNodes[0],s),n.removeFromList("mrow",[s]))}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}},-4]]})},1181:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;othis.maxrow&&(this.maxrow=this.row.length);var t="mtr",e=this.factory.configuration.tags.getTag();e&&(this.row=[e].concat(this.row),t="mlabeledtr"),this.factory.configuration.tags.clearTag();var r=this.create("node",t,this.row);this.table.push(r),this.row=[]},e.prototype.EndTable=function(){t.prototype.EndTable.call(this),this.factory.configuration.tags.end(),this.extendArray("columnalign",this.maxrow),this.extendArray("columnwidth",this.maxrow),this.extendArray("columnspacing",this.maxrow-1)},e.prototype.extendArray=function(t,e){if(this.arraydef[t]){var r=this.arraydef[t].split(/ /),n=s([],i(r),!1);if(n.length>1){for(;n.length",succ:"\u227b",prec:"\u227a",approx:"\u2248",succeq:"\u2ab0",preceq:"\u2aaf",supset:"\u2283",subset:"\u2282",supseteq:"\u2287",subseteq:"\u2286",in:"\u2208",ni:"\u220b",notin:"\u2209",owns:"\u220b",gg:"\u226b",ll:"\u226a",sim:"\u223c",simeq:"\u2243",perp:"\u22a5",equiv:"\u2261",asymp:"\u224d",smile:"\u2323",frown:"\u2322",ne:"\u2260",neq:"\u2260",cong:"\u2245",doteq:"\u2250",bowtie:"\u22c8",models:"\u22a8",notChar:"\u29f8",Leftrightarrow:"\u21d4",Leftarrow:"\u21d0",Rightarrow:"\u21d2",leftrightarrow:"\u2194",leftarrow:"\u2190",gets:"\u2190",rightarrow:"\u2192",to:["\u2192",{accent:!1}],mapsto:"\u21a6",leftharpoonup:"\u21bc",leftharpoondown:"\u21bd",rightharpoonup:"\u21c0",rightharpoondown:"\u21c1",nearrow:"\u2197",searrow:"\u2198",nwarrow:"\u2196",swarrow:"\u2199",rightleftharpoons:"\u21cc",hookrightarrow:"\u21aa",hookleftarrow:"\u21a9",longleftarrow:"\u27f5",Longleftarrow:"\u27f8",longrightarrow:"\u27f6",Longrightarrow:"\u27f9",Longleftrightarrow:"\u27fa",longleftrightarrow:"\u27f7",longmapsto:"\u27fc",ldots:"\u2026",cdots:"\u22ef",vdots:"\u22ee",ddots:"\u22f1",dotsc:"\u2026",dotsb:"\u22ef",dotsm:"\u22ef",dotsi:"\u22ef",dotso:"\u2026",ldotp:[".",{texClass:h.TEXCLASS.PUNCT}],cdotp:["\u22c5",{texClass:h.TEXCLASS.PUNCT}],colon:[":",{texClass:h.TEXCLASS.PUNCT}]}),new a.CharacterMap("mathchar7",u.default.mathchar7,{Gamma:"\u0393",Delta:"\u0394",Theta:"\u0398",Lambda:"\u039b",Xi:"\u039e",Pi:"\u03a0",Sigma:"\u03a3",Upsilon:"\u03a5",Phi:"\u03a6",Psi:"\u03a8",Omega:"\u03a9",_:"_","#":"#",$:"$","%":"%","&":"&",And:"&"}),new a.DelimiterMap("delimiter",u.default.delimiter,{"(":"(",")":")","[":"[","]":"]","<":"\u27e8",">":"\u27e9","\\lt":"\u27e8","\\gt":"\u27e9","/":"/","|":["|",{texClass:h.TEXCLASS.ORD}],".":"","\\\\":"\\","\\lmoustache":"\u23b0","\\rmoustache":"\u23b1","\\lgroup":"\u27ee","\\rgroup":"\u27ef","\\arrowvert":"\u23d0","\\Arrowvert":"\u2016","\\bracevert":"\u23aa","\\Vert":["\u2016",{texClass:h.TEXCLASS.ORD}],"\\|":["\u2016",{texClass:h.TEXCLASS.ORD}],"\\vert":["|",{texClass:h.TEXCLASS.ORD}],"\\uparrow":"\u2191","\\downarrow":"\u2193","\\updownarrow":"\u2195","\\Uparrow":"\u21d1","\\Downarrow":"\u21d3","\\Updownarrow":"\u21d5","\\backslash":"\\","\\rangle":"\u27e9","\\langle":"\u27e8","\\rbrace":"}","\\lbrace":"{","\\}":"}","\\{":"{","\\rceil":"\u2309","\\lceil":"\u2308","\\rfloor":"\u230b","\\lfloor":"\u230a","\\lbrack":"[","\\rbrack":"]"}),new a.CommandMap("macros",{displaystyle:["SetStyle","D",!0,0],textstyle:["SetStyle","T",!1,0],scriptstyle:["SetStyle","S",!1,1],scriptscriptstyle:["SetStyle","SS",!1,2],rm:["SetFont",l.TexConstant.Variant.NORMAL],mit:["SetFont",l.TexConstant.Variant.ITALIC],oldstyle:["SetFont",l.TexConstant.Variant.OLDSTYLE],cal:["SetFont",l.TexConstant.Variant.CALLIGRAPHIC],it:["SetFont",l.TexConstant.Variant.MATHITALIC],bf:["SetFont",l.TexConstant.Variant.BOLD],bbFont:["SetFont",l.TexConstant.Variant.DOUBLESTRUCK],scr:["SetFont",l.TexConstant.Variant.SCRIPT],frak:["SetFont",l.TexConstant.Variant.FRAKTUR],sf:["SetFont",l.TexConstant.Variant.SANSSERIF],tt:["SetFont",l.TexConstant.Variant.MONOSPACE],mathrm:["MathFont",l.TexConstant.Variant.NORMAL],mathup:["MathFont",l.TexConstant.Variant.NORMAL],mathnormal:["MathFont",""],mathbf:["MathFont",l.TexConstant.Variant.BOLD],mathbfup:["MathFont",l.TexConstant.Variant.BOLD],mathit:["MathFont",l.TexConstant.Variant.MATHITALIC],mathbfit:["MathFont",l.TexConstant.Variant.BOLDITALIC],mathbb:["MathFont",l.TexConstant.Variant.DOUBLESTRUCK],Bbb:["MathFont",l.TexConstant.Variant.DOUBLESTRUCK],mathfrak:["MathFont",l.TexConstant.Variant.FRAKTUR],mathbffrak:["MathFont",l.TexConstant.Variant.BOLDFRAKTUR],mathscr:["MathFont",l.TexConstant.Variant.SCRIPT],mathbfscr:["MathFont",l.TexConstant.Variant.BOLDSCRIPT],mathsf:["MathFont",l.TexConstant.Variant.SANSSERIF],mathsfup:["MathFont",l.TexConstant.Variant.SANSSERIF],mathbfsf:["MathFont",l.TexConstant.Variant.BOLDSANSSERIF],mathbfsfup:["MathFont",l.TexConstant.Variant.BOLDSANSSERIF],mathsfit:["MathFont",l.TexConstant.Variant.SANSSERIFITALIC],mathbfsfit:["MathFont",l.TexConstant.Variant.SANSSERIFBOLDITALIC],mathtt:["MathFont",l.TexConstant.Variant.MONOSPACE],mathcal:["MathFont",l.TexConstant.Variant.CALLIGRAPHIC],mathbfcal:["MathFont",l.TexConstant.Variant.BOLDCALLIGRAPHIC],symrm:["MathFont",l.TexConstant.Variant.NORMAL],symup:["MathFont",l.TexConstant.Variant.NORMAL],symnormal:["MathFont",""],symbf:["MathFont",l.TexConstant.Variant.BOLD],symbfup:["MathFont",l.TexConstant.Variant.BOLD],symit:["MathFont",l.TexConstant.Variant.ITALIC],symbfit:["MathFont",l.TexConstant.Variant.BOLDITALIC],symbb:["MathFont",l.TexConstant.Variant.DOUBLESTRUCK],symfrak:["MathFont",l.TexConstant.Variant.FRAKTUR],symbffrak:["MathFont",l.TexConstant.Variant.BOLDFRAKTUR],symscr:["MathFont",l.TexConstant.Variant.SCRIPT],symbfscr:["MathFont",l.TexConstant.Variant.BOLDSCRIPT],symsf:["MathFont",l.TexConstant.Variant.SANSSERIF],symsfup:["MathFont",l.TexConstant.Variant.SANSSERIF],symbfsf:["MathFont",l.TexConstant.Variant.BOLDSANSSERIF],symbfsfup:["MathFont",l.TexConstant.Variant.BOLDSANSSERIF],symsfit:["MathFont",l.TexConstant.Variant.SANSSERIFITALIC],symbfsfit:["MathFont",l.TexConstant.Variant.SANSSERIFBOLDITALIC],symtt:["MathFont",l.TexConstant.Variant.MONOSPACE],symcal:["MathFont",l.TexConstant.Variant.CALLIGRAPHIC],symbfcal:["MathFont",l.TexConstant.Variant.BOLDCALLIGRAPHIC],textrm:["HBox",null,l.TexConstant.Variant.NORMAL],textup:["HBox",null,l.TexConstant.Variant.NORMAL],textnormal:["HBox"],textit:["HBox",null,l.TexConstant.Variant.ITALIC],textbf:["HBox",null,l.TexConstant.Variant.BOLD],textsf:["HBox",null,l.TexConstant.Variant.SANSSERIF],texttt:["HBox",null,l.TexConstant.Variant.MONOSPACE],tiny:["SetSize",.5],Tiny:["SetSize",.6],scriptsize:["SetSize",.7],small:["SetSize",.85],normalsize:["SetSize",1],large:["SetSize",1.2],Large:["SetSize",1.44],LARGE:["SetSize",1.73],huge:["SetSize",2.07],Huge:["SetSize",2.49],arcsin:"NamedFn",arccos:"NamedFn",arctan:"NamedFn",arg:"NamedFn",cos:"NamedFn",cosh:"NamedFn",cot:"NamedFn",coth:"NamedFn",csc:"NamedFn",deg:"NamedFn",det:"NamedOp",dim:"NamedFn",exp:"NamedFn",gcd:"NamedOp",hom:"NamedFn",inf:"NamedOp",ker:"NamedFn",lg:"NamedFn",lim:"NamedOp",liminf:["NamedOp","lim inf"],limsup:["NamedOp","lim sup"],ln:"NamedFn",log:"NamedFn",max:"NamedOp",min:"NamedOp",Pr:"NamedOp",sec:"NamedFn",sin:"NamedFn",sinh:"NamedFn",sup:"NamedOp",tan:"NamedFn",tanh:"NamedFn",limits:["Limits",1],nolimits:["Limits",0],overline:["UnderOver","2015"],underline:["UnderOver","2015"],overbrace:["UnderOver","23DE",1],underbrace:["UnderOver","23DF",1],overparen:["UnderOver","23DC"],underparen:["UnderOver","23DD"],overrightarrow:["UnderOver","2192"],underrightarrow:["UnderOver","2192"],overleftarrow:["UnderOver","2190"],underleftarrow:["UnderOver","2190"],overleftrightarrow:["UnderOver","2194"],underleftrightarrow:["UnderOver","2194"],overset:"Overset",underset:"Underset",overunderset:"Overunderset",stackrel:["Macro","\\mathrel{\\mathop{#2}\\limits^{#1}}",2],stackbin:["Macro","\\mathbin{\\mathop{#2}\\limits^{#1}}",2],over:"Over",overwithdelims:"Over",atop:"Over",atopwithdelims:"Over",above:"Over",abovewithdelims:"Over",brace:["Over","{","}"],brack:["Over","[","]"],choose:["Over","(",")"],frac:"Frac",sqrt:"Sqrt",root:"Root",uproot:["MoveRoot","upRoot"],leftroot:["MoveRoot","leftRoot"],left:"LeftRight",right:"LeftRight",middle:"LeftRight",llap:"Lap",rlap:"Lap",raise:"RaiseLower",lower:"RaiseLower",moveleft:"MoveLeftRight",moveright:"MoveLeftRight",",":["Spacer",f.MATHSPACE.thinmathspace],":":["Spacer",f.MATHSPACE.mediummathspace],">":["Spacer",f.MATHSPACE.mediummathspace],";":["Spacer",f.MATHSPACE.thickmathspace],"!":["Spacer",f.MATHSPACE.negativethinmathspace],enspace:["Spacer",.5],quad:["Spacer",1],qquad:["Spacer",2],thinspace:["Spacer",f.MATHSPACE.thinmathspace],negthinspace:["Spacer",f.MATHSPACE.negativethinmathspace],hskip:"Hskip",hspace:"Hskip",kern:"Hskip",mskip:"Hskip",mspace:"Hskip",mkern:"Hskip",rule:"rule",Rule:["Rule"],Space:["Rule","blank"],nonscript:"Nonscript",big:["MakeBig",h.TEXCLASS.ORD,.85],Big:["MakeBig",h.TEXCLASS.ORD,1.15],bigg:["MakeBig",h.TEXCLASS.ORD,1.45],Bigg:["MakeBig",h.TEXCLASS.ORD,1.75],bigl:["MakeBig",h.TEXCLASS.OPEN,.85],Bigl:["MakeBig",h.TEXCLASS.OPEN,1.15],biggl:["MakeBig",h.TEXCLASS.OPEN,1.45],Biggl:["MakeBig",h.TEXCLASS.OPEN,1.75],bigr:["MakeBig",h.TEXCLASS.CLOSE,.85],Bigr:["MakeBig",h.TEXCLASS.CLOSE,1.15],biggr:["MakeBig",h.TEXCLASS.CLOSE,1.45],Biggr:["MakeBig",h.TEXCLASS.CLOSE,1.75],bigm:["MakeBig",h.TEXCLASS.REL,.85],Bigm:["MakeBig",h.TEXCLASS.REL,1.15],biggm:["MakeBig",h.TEXCLASS.REL,1.45],Biggm:["MakeBig",h.TEXCLASS.REL,1.75],mathord:["TeXAtom",h.TEXCLASS.ORD],mathop:["TeXAtom",h.TEXCLASS.OP],mathopen:["TeXAtom",h.TEXCLASS.OPEN],mathclose:["TeXAtom",h.TEXCLASS.CLOSE],mathbin:["TeXAtom",h.TEXCLASS.BIN],mathrel:["TeXAtom",h.TEXCLASS.REL],mathpunct:["TeXAtom",h.TEXCLASS.PUNCT],mathinner:["TeXAtom",h.TEXCLASS.INNER],vcenter:["TeXAtom",h.TEXCLASS.VCENTER],buildrel:"BuildRel",hbox:["HBox",0],text:"HBox",mbox:["HBox",0],fbox:"FBox",boxed:["Macro","\\fbox{$\\displaystyle{#1}$}",1],framebox:"FrameBox",strut:"Strut",mathstrut:["Macro","\\vphantom{(}"],phantom:"Phantom",vphantom:["Phantom",1,0],hphantom:["Phantom",0,1],smash:"Smash",acute:["Accent","00B4"],grave:["Accent","0060"],ddot:["Accent","00A8"],tilde:["Accent","007E"],bar:["Accent","00AF"],breve:["Accent","02D8"],check:["Accent","02C7"],hat:["Accent","005E"],vec:["Accent","2192"],dot:["Accent","02D9"],widetilde:["Accent","007E",1],widehat:["Accent","005E",1],matrix:"Matrix",array:"Matrix",pmatrix:["Matrix","(",")"],cases:["Matrix","{","","left left",null,".1em",null,!0],eqalign:["Matrix",null,null,"right left",(0,f.em)(f.MATHSPACE.thickmathspace),".5em","D"],displaylines:["Matrix",null,null,"center",null,".5em","D"],cr:"Cr","\\":"CrLaTeX",newline:["CrLaTeX",!0],hline:["HLine","solid"],hdashline:["HLine","dashed"],eqalignno:["Matrix",null,null,"right left",(0,f.em)(f.MATHSPACE.thickmathspace),".5em","D",null,"right"],leqalignno:["Matrix",null,null,"right left",(0,f.em)(f.MATHSPACE.thickmathspace),".5em","D",null,"left"],hfill:"HFill",hfil:"HFill",hfilll:"HFill",bmod:["Macro",'\\mmlToken{mo}[lspace="thickmathspace" rspace="thickmathspace"]{mod}'],pmod:["Macro","\\pod{\\mmlToken{mi}{mod}\\kern 6mu #1}",1],mod:["Macro","\\mathchoice{\\kern18mu}{\\kern12mu}{\\kern12mu}{\\kern12mu}\\mmlToken{mi}{mod}\\,\\,#1",1],pod:["Macro","\\mathchoice{\\kern18mu}{\\kern8mu}{\\kern8mu}{\\kern8mu}(#1)",1],iff:["Macro","\\;\\Longleftrightarrow\\;"],skew:["Macro","{{#2{#3\\mkern#1mu}\\mkern-#1mu}{}}",3],pmb:["Macro","\\rlap{#1}\\kern1px{#1}",1],TeX:["Macro","T\\kern-.14em\\lower.5ex{E}\\kern-.115em X"],LaTeX:["Macro","L\\kern-.325em\\raise.21em{\\scriptstyle{A}}\\kern-.17em\\TeX"]," ":["Macro","\\text{ }"],not:"Not",dots:"Dots",space:"Tilde","\xa0":"Tilde",begin:"BeginEnd",end:"BeginEnd",label:"HandleLabel",ref:"HandleRef",nonumber:"HandleNoTag",mathchoice:"MathChoice",mmlToken:"MmlToken"},c.default),new a.EnvironmentMap("environment",u.default.environment,{array:["AlignedArray"],equation:["Equation",null,!0],eqnarray:["EqnArray",null,!0,!0,"rcl",p.default.cols(0,f.MATHSPACE.thickmathspace),".5em"]},c.default),new a.CharacterMap("not_remap",null,{"\u2190":"\u219a","\u2192":"\u219b","\u2194":"\u21ae","\u21d0":"\u21cd","\u21d2":"\u21cf","\u21d4":"\u21ce","\u2208":"\u2209","\u220b":"\u220c","\u2223":"\u2224","\u2225":"\u2226","\u223c":"\u2241","~":"\u2241","\u2243":"\u2244","\u2245":"\u2247","\u2248":"\u2249","\u224d":"\u226d","=":"\u2260","\u2261":"\u2262","<":"\u226e",">":"\u226f","\u2264":"\u2270","\u2265":"\u2271","\u2272":"\u2274","\u2273":"\u2275","\u2276":"\u2278","\u2277":"\u2279","\u227a":"\u2280","\u227b":"\u2281","\u2282":"\u2284","\u2283":"\u2285","\u2286":"\u2288","\u2287":"\u2289","\u22a2":"\u22ac","\u22a8":"\u22ad","\u22a9":"\u22ae","\u22ab":"\u22af","\u227c":"\u22e0","\u227d":"\u22e1","\u2291":"\u22e2","\u2292":"\u22e3","\u22b2":"\u22ea","\u22b3":"\u22eb","\u22b4":"\u22ec","\u22b5":"\u22ed","\u2203":"\u2204"})},7693:function(t,e,r){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},l=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var c=s(r(1181)),u=l(r(1256)),p=l(r(3971)),h=l(r(8417)),f=r(8317),d=l(r(1130)),m=r(9007),y=r(6521),g=r(6010),b=r(5368),v=r(7233),_={},S={fontfamily:1,fontsize:1,fontweight:1,fontstyle:1,color:1,background:1,id:1,class:1,href:1,style:1};function M(t,e){var r=t.stack.env,n=r.inRoot;r.inRoot=!0;var o=new h.default(e,r,t.configuration),i=o.mml(),s=o.stack.global;if(s.leftRoot||s.upRoot){var a={};s.leftRoot&&(a.width=s.leftRoot),s.upRoot&&(a.voffset=s.upRoot,a.height=s.upRoot),i=t.create("node","mpadded",[i],a)}return r.inRoot=n,i}_.Open=function(t,e){t.Push(t.itemFactory.create("open"))},_.Close=function(t,e){t.Push(t.itemFactory.create("close"))},_.Tilde=function(t,e){t.Push(t.create("token","mtext",{},b.entities.nbsp))},_.Space=function(t,e){},_.Superscript=function(t,e){var r,n,o;t.GetNext().match(/\d/)&&(t.string=t.string.substr(0,t.i+1)+" "+t.string.substr(t.i+1));var i=t.stack.Top();i.isKind("prime")?(o=(r=a(i.Peek(2),2))[0],n=r[1],t.stack.Pop()):(o=t.stack.Prev())||(o=t.create("token","mi",{},""));var s=u.default.getProperty(o,"movesupsub"),l=u.default.isType(o,"msubsup")?o.sup:o.over;if(u.default.isType(o,"msubsup")&&!u.default.isType(o,"msup")&&u.default.getChildAt(o,o.sup)||u.default.isType(o,"munderover")&&!u.default.isType(o,"mover")&&u.default.getChildAt(o,o.over)&&!u.default.getProperty(o,"subsupOK"))throw new p.default("DoubleExponent","Double exponent: use braces to clarify");u.default.isType(o,"msubsup")&&!u.default.isType(o,"msup")||(s?((!u.default.isType(o,"munderover")||u.default.isType(o,"mover")||u.default.getChildAt(o,o.over))&&(o=t.create("node","munderover",[o],{movesupsub:!0})),l=o.over):l=(o=t.create("node","msubsup",[o])).sup),t.Push(t.itemFactory.create("subsup",o).setProperties({position:l,primes:n,movesupsub:s}))},_.Subscript=function(t,e){var r,n,o;t.GetNext().match(/\d/)&&(t.string=t.string.substr(0,t.i+1)+" "+t.string.substr(t.i+1));var i=t.stack.Top();i.isKind("prime")?(o=(r=a(i.Peek(2),2))[0],n=r[1],t.stack.Pop()):(o=t.stack.Prev())||(o=t.create("token","mi",{},""));var s=u.default.getProperty(o,"movesupsub"),l=u.default.isType(o,"msubsup")?o.sub:o.under;if(u.default.isType(o,"msubsup")&&!u.default.isType(o,"msup")&&u.default.getChildAt(o,o.sub)||u.default.isType(o,"munderover")&&!u.default.isType(o,"mover")&&u.default.getChildAt(o,o.under)&&!u.default.getProperty(o,"subsupOK"))throw new p.default("DoubleSubscripts","Double subscripts: use braces to clarify");u.default.isType(o,"msubsup")&&!u.default.isType(o,"msup")||(s?((!u.default.isType(o,"munderover")||u.default.isType(o,"mover")||u.default.getChildAt(o,o.under))&&(o=t.create("node","munderover",[o],{movesupsub:!0})),l=o.under):l=(o=t.create("node","msubsup",[o])).sub),t.Push(t.itemFactory.create("subsup",o).setProperties({position:l,primes:n,movesupsub:s}))},_.Prime=function(t,e){var r=t.stack.Prev();if(r||(r=t.create("node","mi")),u.default.isType(r,"msubsup")&&!u.default.isType(r,"msup")&&u.default.getChildAt(r,r.sup))throw new p.default("DoubleExponentPrime","Prime causes double exponent: use braces to clarify");var n="";t.i--;do{n+=b.entities.prime,t.i++,e=t.GetNext()}while("'"===e||e===b.entities.rsquo);n=["","\u2032","\u2033","\u2034","\u2057"][n.length]||n;var o=t.create("token","mo",{variantForm:!0},n);t.Push(t.itemFactory.create("prime",r,o))},_.Comment=function(t,e){for(;t.i=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.ConfigMacrosConfiguration=void 0;var s=r(9899),a=r(7233),l=r(9140),c=i(r(5450)),u=r(8803),p=i(r(1110)),h=r(6793),f="configmacros-map",d="configmacros-env-map";e.ConfigMacrosConfiguration=s.Configuration.create("configmacros",{init:function(t){new l.CommandMap(f,{},{}),new l.EnvironmentMap(d,c.default.environment,{},{}),t.append(s.Configuration.local({handler:{macro:[f],environment:[d]},priority:3}))},config:function(t,e){!function(t){var e,r,n=t.parseOptions.handlers.retrieve(f),i=t.parseOptions.options.macros;try{for(var s=o(Object.keys(i)),a=s.next();!a.done;a=s.next()){var l=a.value,c="string"==typeof i[l]?[i[l]]:i[l],h=Array.isArray(c[2])?new u.Macro(l,p.default.MacroWithTemplate,c.slice(0,2).concat(c[2])):new u.Macro(l,p.default.Macro,c);n.add(l,h)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}}(e),function(t){var e,r,n=t.parseOptions.handlers.retrieve(d),i=t.parseOptions.options.environments;try{for(var s=o(Object.keys(i)),a=s.next();!a.done;a=s.next()){var l=a.value;n.add(l,new u.Macro(l,p.default.BeginEnv,[!0].concat(i[l])))}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}}(e)},items:(n={},n[h.BeginEnvItem.prototype.kind]=h.BeginEnvItem,n),options:{macros:(0,a.expandable)({}),environments:(0,a.expandable)({})}})},1496:function(t,e,r){var n,o=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),i=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),s=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&o(e,t,r);return i(e,t),e},a=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.NewcommandConfiguration=void 0;var l=r(9899),c=r(6793),u=a(r(5579));r(5117);var p=a(r(5450)),h=s(r(9140));e.NewcommandConfiguration=l.Configuration.create("newcommand",{handler:{macro:["Newcommand-macros"]},items:(n={},n[c.BeginEnvItem.prototype.kind]=c.BeginEnvItem,n),options:{maxMacros:1e3},init:function(t){new h.DelimiterMap(u.default.NEW_DELIMITER,p.default.delimiter,{}),new h.CommandMap(u.default.NEW_COMMAND,{},{}),new h.EnvironmentMap(u.default.NEW_ENVIRONMENT,p.default.environment,{},{}),t.append(l.Configuration.local({handler:{character:[],delimiter:[u.default.NEW_DELIMITER],macro:[u.default.NEW_DELIMITER,u.default.NEW_COMMAND],environment:[u.default.NEW_ENVIRONMENT]},priority:-1}))}})},6793:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.BeginEnvItem=void 0;var s=i(r(3971)),a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),Object.defineProperty(e.prototype,"kind",{get:function(){return"beginEnv"},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"isOpen",{get:function(){return!0},enumerable:!1,configurable:!0}),e.prototype.checkItem=function(e){if(e.isKind("end")){if(e.getName()!==this.getName())throw new s.default("EnvBadEnd","\\begin{%1} ended with \\end{%2}",this.getName(),e.getName());return[[this.factory.create("mml",this.toMml())],!0]}if(e.isKind("stop"))throw new s.default("EnvMissingEnd","Missing \\end{%1}",this.getName());return t.prototype.checkItem.call(this,e)},e}(r(8292).BaseItem);e.BeginEnvItem=a},5117:function(t,e,r){var n=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var o=n(r(1110));new(r(9140).CommandMap)("Newcommand-macros",{newcommand:"NewCommand",renewcommand:"NewCommand",newenvironment:"NewEnvironment",renewenvironment:"NewEnvironment",def:"MacroDef",let:"Let"},o.default)},1110:function(t,e,r){var n=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),o=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),i=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&n(e,t,r);return o(e,t),e},s=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0});var a=s(r(3971)),l=i(r(9140)),c=s(r(7693)),u=s(r(1130)),p=s(r(5579)),h={NewCommand:function(t,e){var r=p.default.GetCsNameArgument(t,e),n=p.default.GetArgCount(t,e),o=t.GetBrackets(e),i=t.GetArgument(e);p.default.addMacro(t,r,h.Macro,[i,n,o])},NewEnvironment:function(t,e){var r=u.default.trimSpaces(t.GetArgument(e)),n=p.default.GetArgCount(t,e),o=t.GetBrackets(e),i=t.GetArgument(e),s=t.GetArgument(e);p.default.addEnvironment(t,r,h.BeginEnv,[!0,i,s,n,o])},MacroDef:function(t,e){var r=p.default.GetCSname(t,e),n=p.default.GetTemplate(t,e,"\\"+r),o=t.GetArgument(e);n instanceof Array?p.default.addMacro(t,r,h.MacroWithTemplate,[o].concat(n)):p.default.addMacro(t,r,h.Macro,[o,n])},Let:function(t,e){var r=p.default.GetCSname(t,e),n=t.GetNext();"="===n&&(t.i++,n=t.GetNext());var o=t.configuration.handlers;if("\\"!==n){t.i++;var i=o.get("delimiter").lookup(n);i?p.default.addDelimiter(t,"\\"+r,i.char,i.attributes):p.default.addMacro(t,r,h.Macro,[n])}else{e=p.default.GetCSname(t,e);var s=o.get("delimiter").lookup("\\"+e);if(s)return void p.default.addDelimiter(t,"\\"+r,s.char,s.attributes);var a=o.get("macro").applicable(e);if(!a)return;if(a instanceof l.MacroMap){var c=a.lookup(e);return void p.default.addMacro(t,r,c.func,c.args,c.symbol)}s=a.lookup(e);var u=p.default.disassembleSymbol(r,s);p.default.addMacro(t,r,(function(t,e){for(var r=[],n=2;n0?[i.toString()].concat(o):i;t.i++}throw new s.default("MissingReplacementString","Missing replacement string for definition of %1",e)},t.GetParameter=function(t,r,n){if(null==n)return t.GetArgument(r);for(var o=t.i,i=0,a=0;t.i=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.NoUndefinedConfiguration=void 0;var o=r(9899);e.NoUndefinedConfiguration=o.Configuration.create("noundefined",{fallback:{macro:function(t,e){var r,o,i=t.create("text","\\"+e),s=t.options.noundefined||{},a={};try{for(var l=n(["color","background","size"]),c=l.next();!c.done;c=l.next()){var u=c.value;s[u]&&(a["math"+u]=s[u])}}catch(t){r={error:t}}finally{try{c&&!c.done&&(o=l.return)&&o.call(l)}finally{if(r)throw r.error}}t.Push(t.create("node","mtext",[],a,i))}},options:{noundefined:{color:"red",background:"",size:""}},priority:3})},7741:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTML=void 0;var u=r(3055),p=r(4139),h=r(9261),f=r(6797),d=r(2760),m=l(r(6010)),y=r(505),g=function(t){function e(e){void 0===e&&(e=null);var r=t.call(this,e,h.CHTMLWrapperFactory,d.TeXFont)||this;return r.chtmlStyles=null,r.font.adaptiveCSS(r.options.adaptiveCSS),r.wrapperUsage=new f.Usage,r}return o(e,t),e.prototype.escaped=function(t,e){return this.setDocument(e),this.html("span",{},[this.text(t.math)])},e.prototype.styleSheet=function(r){if(this.chtmlStyles){if(this.options.adaptiveCSS){var n=new p.CssStyles;this.addWrapperStyles(n),this.updateFontStyles(n),this.adaptor.insertRules(this.chtmlStyles,n.getStyleRules())}return this.chtmlStyles}var o=this.chtmlStyles=t.prototype.styleSheet.call(this,r);return this.adaptor.setAttribute(o,"id",e.STYLESHEETID),this.wrapperUsage.update(),o},e.prototype.updateFontStyles=function(t){t.addStyles(this.font.updateStyles({}))},e.prototype.addWrapperStyles=function(e){var r,n;if(this.options.adaptiveCSS)try{for(var o=c(this.wrapperUsage.update()),i=o.next();!i.done;i=o.next()){var s=i.value,a=this.factory.getNodeClass(s);a&&this.addClassStyles(a,e)}}catch(t){r={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}else t.prototype.addWrapperStyles.call(this,e)},e.prototype.addClassStyles=function(e,r){var n,o=e;o.autoStyle&&"unknown"!==o.kind&&r.addStyles(((n={})["mjx-"+o.kind]={display:"inline-block","text-align":"left"},n)),this.wrapperUsage.add(o.kind),t.prototype.addClassStyles.call(this,e,r)},e.prototype.processMath=function(t,e){this.factory.wrap(t).toCHTML(e)},e.prototype.clearCache=function(){this.cssStyles.clear(),this.font.clearCache(),this.wrapperUsage.clear(),this.chtmlStyles=null},e.prototype.reset=function(){this.clearCache()},e.prototype.unknownText=function(t,e,r){void 0===r&&(r=null);var n={},o=100/this.math.metrics.scale;if(100!==o&&(n["font-size"]=this.fixed(o,1)+"%",n.padding=m.em(75/o)+" 0 "+m.em(20/o)+" 0"),"-explicitFont"!==e){var i=(0,y.unicodeChars)(t);(1!==i.length||i[0]<119808||i[0]>120831)&&this.cssFontStyles(this.font.getCssFont(e),n)}if(null!==r){var s=this.math.metrics;n.width=Math.round(r*s.em*s.scale)+"px"}return this.html("mjx-utext",{variant:e,style:n},[this.text(t)])},e.prototype.measureTextNode=function(t){var e=this.adaptor,r=e.clone(t);e.setStyle(r,"font-family",e.getStyle(r,"font-family").replace(/MJXZERO, /g,""));var n=this.html("mjx-measure-text",{style:{position:"absolute","white-space":"nowrap"}},[r]);e.append(e.parent(this.math.start.node),this.container),e.append(this.container,n);var o=e.nodeSize(r,this.math.metrics.em)[0]/this.math.metrics.scale;return e.remove(this.container),e.remove(n),{w:o,h:.75,d:.2}},e.NAME="CHTML",e.OPTIONS=i(i({},u.CommonOutputJax.OPTIONS),{adaptiveCSS:!0,matchFontHeight:!0}),e.commonStyles={'mjx-container[jax="CHTML"]':{"line-height":0},'mjx-container [space="1"]':{"margin-left":".111em"},'mjx-container [space="2"]':{"margin-left":".167em"},'mjx-container [space="3"]':{"margin-left":".222em"},'mjx-container [space="4"]':{"margin-left":".278em"},'mjx-container [space="5"]':{"margin-left":".333em"},'mjx-container [rspace="1"]':{"margin-right":".111em"},'mjx-container [rspace="2"]':{"margin-right":".167em"},'mjx-container [rspace="3"]':{"margin-right":".222em"},'mjx-container [rspace="4"]':{"margin-right":".278em"},'mjx-container [rspace="5"]':{"margin-right":".333em"},'mjx-container [size="s"]':{"font-size":"70.7%"},'mjx-container [size="ss"]':{"font-size":"50%"},'mjx-container [size="Tn"]':{"font-size":"60%"},'mjx-container [size="sm"]':{"font-size":"85%"},'mjx-container [size="lg"]':{"font-size":"120%"},'mjx-container [size="Lg"]':{"font-size":"144%"},'mjx-container [size="LG"]':{"font-size":"173%"},'mjx-container [size="hg"]':{"font-size":"207%"},'mjx-container [size="HG"]':{"font-size":"249%"},'mjx-container [width="full"]':{width:"100%"},"mjx-box":{display:"inline-block"},"mjx-block":{display:"block"},"mjx-itable":{display:"inline-table"},"mjx-row":{display:"table-row"},"mjx-row > *":{display:"table-cell"},"mjx-mtext":{display:"inline-block"},"mjx-mstyle":{display:"inline-block"},"mjx-merror":{display:"inline-block",color:"red","background-color":"yellow"},"mjx-mphantom":{visibility:"hidden"},"_::-webkit-full-page-media, _:future, :root mjx-container":{"will-change":"opacity"}},e.STYLESHEETID="MJX-CHTML-styles",e}(u.CommonOutputJax);e.CHTML=g},8042:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},c=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.AddCSS=e.CHTMLFontData=void 0;var u=r(5884),p=r(6797),h=r(6010);a(r(5884),e);var f=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.charUsage=new p.Usage,e.delimUsage=new p.Usage,e}return o(e,t),e.charOptions=function(e,r){return t.charOptions.call(this,e,r)},e.prototype.adaptiveCSS=function(t){this.options.adaptiveCSS=t},e.prototype.clearCache=function(){this.options.adaptiveCSS&&(this.charUsage.clear(),this.delimUsage.clear())},e.prototype.createVariant=function(e,r,n){void 0===r&&(r=null),void 0===n&&(n=null),t.prototype.createVariant.call(this,e,r,n);var o=this.constructor;this.variant[e].classes=o.defaultVariantClasses[e],this.variant[e].letter=o.defaultVariantLetters[e]},e.prototype.defineChars=function(r,n){var o,i;t.prototype.defineChars.call(this,r,n);var s=this.variant[r].letter;try{for(var a=l(Object.keys(n)),c=a.next();!c.done;c=a.next()){var u=c.value,p=e.charOptions(n,parseInt(u));void 0===p.f&&(p.f=s)}}catch(t){o={error:t}}finally{try{c&&!c.done&&(i=a.return)&&i.call(a)}finally{if(o)throw o.error}}},Object.defineProperty(e.prototype,"styles",{get:function(){var t=this.constructor,e=i({},t.defaultStyles);return this.addFontURLs(e,t.defaultFonts,this.options.fontURL),this.options.adaptiveCSS?this.updateStyles(e):this.allStyles(e),e},enumerable:!1,configurable:!0}),e.prototype.updateStyles=function(t){var e,r,n,o;try{for(var i=l(this.delimUsage.update()),s=i.next();!s.done;s=i.next()){var a=s.value;this.addDelimiterStyles(t,a,this.delimiters[a])}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}try{for(var u=l(this.charUsage.update()),p=u.next();!p.done;p=u.next()){var h=c(p.value,2),f=h[0],d=(a=h[1],this.variant[f]);this.addCharStyles(t,d.letter,a,d.chars[a])}}catch(t){n={error:t}}finally{try{p&&!p.done&&(o=u.return)&&o.call(u)}finally{if(n)throw n.error}}return t},e.prototype.allStyles=function(t){var e,r,n,o,i,s;try{for(var a=l(Object.keys(this.delimiters)),c=a.next();!c.done;c=a.next()){var u=c.value,p=parseInt(u);this.addDelimiterStyles(t,p,this.delimiters[p])}}catch(t){e={error:t}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(e)throw e.error}}try{for(var h=l(Object.keys(this.variant)),f=h.next();!f.done;f=h.next()){var d=f.value,m=this.variant[d],y=m.letter;try{for(var g=(i=void 0,l(Object.keys(m.chars))),b=g.next();!b.done;b=g.next()){u=b.value,p=parseInt(u);var v=m.chars[p];(v[3]||{}).smp||(v.length<4&&(v[3]={}),this.addCharStyles(t,y,p,v))}}catch(t){i={error:t}}finally{try{b&&!b.done&&(s=g.return)&&s.call(g)}finally{if(i)throw i.error}}}}catch(t){n={error:t}}finally{try{f&&!f.done&&(o=h.return)&&o.call(h)}finally{if(n)throw n.error}}},e.prototype.addFontURLs=function(t,e,r){var n,o;try{for(var s=l(Object.keys(e)),a=s.next();!a.done;a=s.next()){var c=a.value,u=i({},e[c]);u.src=u.src.replace(/%%URL%%/,r),t[c]=u}}catch(t){n={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(n)throw n.error}}},e.prototype.addDelimiterStyles=function(t,e,r){var n=this.charSelector(e);r.c&&r.c!==e&&(t[".mjx-stretched mjx-c"+(n=this.charSelector(r.c))+"::before"]={content:this.charContent(r.c)}),r.stretch&&(1===r.dir?this.addDelimiterVStyles(t,n,r):this.addDelimiterHStyles(t,n,r))},e.prototype.addDelimiterVStyles=function(t,e,r){var n=r.HDW,o=c(r.stretch,4),i=o[0],s=o[1],a=o[2],l=o[3],u=this.addDelimiterVPart(t,e,"beg",i,n);this.addDelimiterVPart(t,e,"ext",s,n);var p=this.addDelimiterVPart(t,e,"end",a,n),h={};if(l){var f=this.addDelimiterVPart(t,e,"mid",l,n);h.height="50%",t["mjx-stretchy-v"+e+" > mjx-mid"]={"margin-top":this.em(-f/2),"margin-bottom":this.em(-f/2)}}u&&(h["border-top-width"]=this.em0(u-.03)),p&&(h["border-bottom-width"]=this.em0(p-.03),t["mjx-stretchy-v"+e+" > mjx-end"]={"margin-top":this.em(-p)}),Object.keys(h).length&&(t["mjx-stretchy-v"+e+" > mjx-ext"]=h)},e.prototype.addDelimiterVPart=function(t,e,r,n,o){if(!n)return 0;var i=this.getDelimiterData(n),s=(o[2]-i[2])/2,a={content:this.charContent(n)};return"ext"!==r?a.padding=this.padding(i,s):(a.width=this.em0(o[2]),s&&(a["padding-left"]=this.em0(s))),t["mjx-stretchy-v"+e+" mjx-"+r+" mjx-c::before"]=a,i[0]+i[1]},e.prototype.addDelimiterHStyles=function(t,e,r){var n=c(r.stretch,4),o=n[0],i=n[1],s=n[2],a=n[3],l=r.HDW;this.addDelimiterHPart(t,e,"beg",o,l),this.addDelimiterHPart(t,e,"ext",i,l),this.addDelimiterHPart(t,e,"end",s,l),a&&(this.addDelimiterHPart(t,e,"mid",a,l),t["mjx-stretchy-h"+e+" > mjx-ext"]={width:"50%"})},e.prototype.addDelimiterHPart=function(t,e,r,n,o){if(n){var i=this.getDelimiterData(n)[3],s={content:i&&i.c?'"'+i.c+'"':this.charContent(n)};s.padding=this.padding(o,0,-o[2]),t["mjx-stretchy-h"+e+" mjx-"+r+" mjx-c::before"]=s}},e.prototype.addCharStyles=function(t,e,r,n){var o=n[3],i=void 0!==o.f?o.f:e;t["mjx-c"+this.charSelector(r)+(i?".TEX-"+i:"")+"::before"]={padding:this.padding(n,0,o.ic||0),content:null!=o.c?'"'+o.c+'"':this.charContent(r)}},e.prototype.getDelimiterData=function(t){return this.getChar("-smallop",t)},e.prototype.em=function(t){return(0,h.em)(t)},e.prototype.em0=function(t){return(0,h.em)(Math.max(0,t))},e.prototype.padding=function(t,e,r){var n=c(t,3),o=n[0],i=n[1];return void 0===e&&(e=0),void 0===r&&(r=0),[o,n[2]+r,i,e].map(this.em0).join(" ")},e.prototype.charContent=function(t){return'"'+(t>=32&&t<=126&&34!==t&&39!==t&&92!==t?String.fromCharCode(t):"\\"+t.toString(16).toUpperCase())+'"'},e.prototype.charSelector=function(t){return".mjx-c"+t.toString(16).toUpperCase()},e.OPTIONS=i(i({},u.FontData.OPTIONS),{fontURL:"js/output/chtml/fonts/tex-woff-v2"}),e.JAX="CHTML",e.defaultVariantClasses={},e.defaultVariantLetters={},e.defaultStyles={"mjx-c::before":{display:"block",width:0}},e.defaultFonts={"@font-face /* 0 */":{"font-family":"MJXZERO",src:'url("%%URL%%/MathJax_Zero.woff") format("woff")'}},e}(u.FontData);e.CHTMLFontData=f,e.AddCSS=function(t,e){var r,n;try{for(var o=l(Object.keys(e)),i=o.next();!i.done;i=o.next()){var s=i.value,a=parseInt(s);Object.assign(u.FontData.charOptions(t,a),e[a])}}catch(t){r={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(r)throw r.error}}return t}},8270:function(t,e,r){var n=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),o=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),i=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&n(e,t,r);return o(e,t),e},s=this&&this.__exportStar||function(t,e){for(var r in t)"default"===r||Object.prototype.hasOwnProperty.call(e,r)||n(e,t,r)},a=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.Arrow=e.DiagonalArrow=e.DiagonalStrike=e.Border2=e.Border=e.RenderElement=void 0;var l=i(r(5552));s(r(5552),e);e.RenderElement=function(t,e){return void 0===e&&(e=""),function(r,n){var o=r.adjustBorder(r.html("mjx-"+t));if(e){var i=r.getOffset(e);if(r.thickness!==l.THICKNESS||i){var s="translate".concat(e,"(").concat(r.em(r.thickness/2-i),")");r.adaptor.setStyle(o,"transform",s)}}r.adaptor.append(r.chtml,o)}};e.Border=function(t){return l.CommonBorder((function(e,r){e.adaptor.setStyle(r,"border-"+t,e.em(e.thickness)+" solid")}))(t)};e.Border2=function(t,e,r){return l.CommonBorder2((function(t,n){var o=t.em(t.thickness)+" solid";t.adaptor.setStyle(n,"border-"+e,o),t.adaptor.setStyle(n,"border-"+r,o)}))(t,e,r)};e.DiagonalStrike=function(t,e){return l.CommonDiagonalStrike((function(t){return function(r,n){var o=r.getBBox(),i=o.w,s=o.h,l=o.d,c=a(r.getArgMod(i,s+l),2),u=c[0],p=c[1],h=e*r.thickness/2,f=r.adjustBorder(r.html(t,{style:{width:r.em(p),transform:"rotate("+r.fixed(-e*u)+"rad) translateY("+h+"em)"}}));r.adaptor.append(r.chtml,f)}}))(t)};e.DiagonalArrow=function(t){return l.CommonDiagonalArrow((function(t,e){t.adaptor.append(t.chtml,e)}))(t)};e.Arrow=function(t){return l.CommonArrow((function(t,e){t.adaptor.append(t.chtml,e)}))(t)}},6797:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Usage=void 0;var r=function(){function t(){this.used=new Set,this.needsUpdate=[]}return t.prototype.add=function(t){var e=JSON.stringify(t);this.used.has(e)||this.needsUpdate.push(t),this.used.add(e)},t.prototype.has=function(t){return this.used.has(JSON.stringify(t))},t.prototype.clear=function(){this.used.clear(),this.needsUpdate=[]},t.prototype.update=function(){var t=this.needsUpdate;return this.needsUpdate=[],t},t}();e.Usage=r},5355:function(t,e,r){var n,o,i=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),s=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),a=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),l=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&s(e,t,r);return a(e,t),e},c=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},u=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLWrapper=e.SPACE=e.FONTSIZE=void 0;var p=l(r(6010)),h=r(7519),f=r(6469);e.FONTSIZE={"70.7%":"s","70%":"s","50%":"ss","60%":"Tn","85%":"sm","120%":"lg","144%":"Lg","173%":"LG","207%":"hg","249%":"HG"},e.SPACE=((o={})[p.em(2/18)]="1",o[p.em(3/18)]="2",o[p.em(4/18)]="3",o[p.em(5/18)]="4",o[p.em(6/18)]="5",o);var d=function(t){function r(){var e=null!==t&&t.apply(this,arguments)||this;return e.chtml=null,e}return i(r,t),r.prototype.toCHTML=function(t){var e,r,n=this.standardCHTMLnode(t);try{for(var o=c(this.childNodes),i=o.next();!i.done;i=o.next()){i.value.toCHTML(n)}}catch(t){e={error:t}}finally{try{i&&!i.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}},r.prototype.standardCHTMLnode=function(t){this.markUsed();var e=this.createCHTMLnode(t);return this.handleStyles(),this.handleVariant(),this.handleScale(),this.handleColor(),this.handleSpace(),this.handleAttributes(),this.handlePWidth(),e},r.prototype.markUsed=function(){this.jax.wrapperUsage.add(this.kind)},r.prototype.createCHTMLnode=function(t){var e=this.node.attributes.get("href");return e&&(t=this.adaptor.append(t,this.html("a",{href:e}))),this.chtml=this.adaptor.append(t,this.html("mjx-"+this.node.kind)),this.chtml},r.prototype.handleStyles=function(){if(this.styles){var t=this.styles.cssText;if(t){this.adaptor.setAttribute(this.chtml,"style",t);var e=this.styles.get("font-family");e&&this.adaptor.setStyle(this.chtml,"font-family","MJXZERO, "+e)}}},r.prototype.handleVariant=function(){this.node.isToken&&"-explicitFont"!==this.variant&&this.adaptor.setAttribute(this.chtml,"class",(this.font.getVariant(this.variant)||this.font.getVariant("normal")).classes)},r.prototype.handleScale=function(){this.setScale(this.chtml,this.bbox.rscale)},r.prototype.setScale=function(t,r){var n=Math.abs(r-1)<.001?1:r;if(t&&1!==n){var o=this.percent(n);e.FONTSIZE[o]?this.adaptor.setAttribute(t,"size",e.FONTSIZE[o]):this.adaptor.setStyle(t,"fontSize",o)}return t},r.prototype.handleSpace=function(){var t,r;try{for(var n=c([[this.bbox.L,"space","marginLeft"],[this.bbox.R,"rspace","marginRight"]]),o=n.next();!o.done;o=n.next()){var i=o.value,s=u(i,3),a=s[0],l=s[1],p=s[2];if(a){var h=this.em(a);e.SPACE[h]?this.adaptor.setAttribute(this.chtml,l,e.SPACE[h]):this.adaptor.setStyle(this.chtml,p,h)}}}catch(e){t={error:e}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(t)throw t.error}}},r.prototype.handleColor=function(){var t=this.node.attributes,e=t.getExplicit("mathcolor"),r=t.getExplicit("color"),n=t.getExplicit("mathbackground"),o=t.getExplicit("background");(e||r)&&this.adaptor.setStyle(this.chtml,"color",e||r),(n||o)&&this.adaptor.setStyle(this.chtml,"backgroundColor",n||o)},r.prototype.handleAttributes=function(){var t,e,n,o,i=this.node.attributes,s=i.getAllDefaults(),a=r.skipAttributes;try{for(var l=c(i.getExplicitNames()),u=l.next();!u.done;u=l.next()){var p=u.value;!1!==a[p]&&(p in s||a[p]||this.adaptor.hasAttribute(this.chtml,p))||this.adaptor.setAttribute(this.chtml,p,i.getExplicit(p))}}catch(e){t={error:e}}finally{try{u&&!u.done&&(e=l.return)&&e.call(l)}finally{if(t)throw t.error}}if(i.get("class")){var h=i.get("class").trim().split(/ +/);try{for(var f=c(h),d=f.next();!d.done;d=f.next()){var m=d.value;this.adaptor.addClass(this.chtml,m)}}catch(t){n={error:t}}finally{try{d&&!d.done&&(o=f.return)&&o.call(f)}finally{if(n)throw n.error}}}},r.prototype.handlePWidth=function(){this.bbox.pwidth&&(this.bbox.pwidth===f.BBox.fullWidth?this.adaptor.setAttribute(this.chtml,"width","full"):this.adaptor.setStyle(this.chtml,"width",this.bbox.pwidth))},r.prototype.setIndent=function(t,e,r){var n=this.adaptor;if("center"===e||"left"===e){var o=this.getBBox().L;n.setStyle(t,"margin-left",this.em(r+o))}if("center"===e||"right"===e){var i=this.getBBox().R;n.setStyle(t,"margin-right",this.em(-r+i))}},r.prototype.drawBBox=function(){var t=this.getBBox(),e=t.w,r=t.h,n=t.d,o=t.R,i=this.html("mjx-box",{style:{opacity:.25,"margin-left":this.em(-e-o)}},[this.html("mjx-box",{style:{height:this.em(r),width:this.em(e),"background-color":"red"}}),this.html("mjx-box",{style:{height:this.em(n),width:this.em(e),"margin-left":this.em(-e),"vertical-align":this.em(-n),"background-color":"green"}})]),s=this.chtml||this.parent.chtml,a=this.adaptor.getAttribute(s,"size");a&&this.adaptor.setAttribute(i,"size",a);var l=this.adaptor.getStyle(s,"fontSize");l&&this.adaptor.setStyle(i,"fontSize",l),this.adaptor.append(this.adaptor.parent(s),i),this.adaptor.setStyle(s,"backgroundColor","#FFEE00")},r.prototype.html=function(t,e,r){return void 0===e&&(e={}),void 0===r&&(r=[]),this.jax.html(t,e,r)},r.prototype.text=function(t){return this.jax.text(t)},r.prototype.char=function(t){return this.font.charSelector(t).substr(1)},r.kind="unknown",r.autoStyle=!0,r}(h.CommonWrapper);e.CHTMLWrapper=d},9261:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLWrapperFactory=void 0;var i=r(4420),s=r(9086),a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.defaultNodes=s.CHTMLWrappers,e}(i.CommonWrapperFactory);e.CHTMLWrapperFactory=a},9086:function(t,e,r){var n;Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLWrappers=void 0;var o=r(5355),i=r(804),s=r(1653),a=r(6287),l=r(6460),c=r(4597),u=r(1259),p=r(2970),h=r(5964),f=r(8147),d=r(4798),m=r(2275),y=r(9063),g=r(5610),b=r(8776),v=r(4300),_=r(6590),S=r(6781),M=r(8002),O=r(3571),x=r(7056),E=r(8102),A=r(6911),C=r(421),T=r(95),N=r(1148);e.CHTMLWrappers=((n={})[i.CHTMLmath.kind]=i.CHTMLmath,n[d.CHTMLmrow.kind]=d.CHTMLmrow,n[d.CHTMLinferredMrow.kind]=d.CHTMLinferredMrow,n[s.CHTMLmi.kind]=s.CHTMLmi,n[a.CHTMLmo.kind]=a.CHTMLmo,n[l.CHTMLmn.kind]=l.CHTMLmn,n[c.CHTMLms.kind]=c.CHTMLms,n[u.CHTMLmtext.kind]=u.CHTMLmtext,n[p.CHTMLmspace.kind]=p.CHTMLmspace,n[h.CHTMLmpadded.kind]=h.CHTMLmpadded,n[f.CHTMLmenclose.kind]=f.CHTMLmenclose,n[y.CHTMLmfrac.kind]=y.CHTMLmfrac,n[g.CHTMLmsqrt.kind]=g.CHTMLmsqrt,n[b.CHTMLmroot.kind]=b.CHTMLmroot,n[v.CHTMLmsub.kind]=v.CHTMLmsub,n[v.CHTMLmsup.kind]=v.CHTMLmsup,n[v.CHTMLmsubsup.kind]=v.CHTMLmsubsup,n[_.CHTMLmunder.kind]=_.CHTMLmunder,n[_.CHTMLmover.kind]=_.CHTMLmover,n[_.CHTMLmunderover.kind]=_.CHTMLmunderover,n[S.CHTMLmmultiscripts.kind]=S.CHTMLmmultiscripts,n[m.CHTMLmfenced.kind]=m.CHTMLmfenced,n[M.CHTMLmtable.kind]=M.CHTMLmtable,n[O.CHTMLmtr.kind]=O.CHTMLmtr,n[O.CHTMLmlabeledtr.kind]=O.CHTMLmlabeledtr,n[x.CHTMLmtd.kind]=x.CHTMLmtd,n[E.CHTMLmaction.kind]=E.CHTMLmaction,n[A.CHTMLmglyph.kind]=A.CHTMLmglyph,n[C.CHTMLsemantics.kind]=C.CHTMLsemantics,n[C.CHTMLannotation.kind]=C.CHTMLannotation,n[C.CHTMLannotationXML.kind]=C.CHTMLannotationXML,n[C.CHTMLxml.kind]=C.CHTMLxml,n[T.CHTMLTeXAtom.kind]=T.CHTMLTeXAtom,n[N.CHTMLTextNode.kind]=N.CHTMLTextNode,n[o.CHTMLWrapper.kind]=o.CHTMLWrapper,n)},95:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLTeXAtom=void 0;var i=r(5355),s=r(9800),a=r(3948),l=r(9007),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){if(t.prototype.toCHTML.call(this,e),this.adaptor.setAttribute(this.chtml,"texclass",l.TEXCLASSNAMES[this.node.texClass]),this.node.texClass===l.TEXCLASS.VCENTER){var r=this.childNodes[0].getBBox(),n=r.h,o=(n+r.d)/2+this.font.params.axis_height-n;this.adaptor.setStyle(this.chtml,"verticalAlign",this.em(o))}},e.kind=a.TeXAtom.prototype.kind,e}((0,s.CommonTeXAtomMixin)(i.CHTMLWrapper));e.CHTMLTeXAtom=c},1148:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLTextNode=void 0;var s=r(9007),a=r(5355),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r;this.markUsed();var n=this.adaptor,o=this.parent.variant,s=this.node.getText();if(0!==s.length)if("-explicitFont"===o)n.append(t,this.jax.unknownText(s,o,this.getBBox().w));else{var a=this.remappedText(s,o);try{for(var l=i(a),c=l.next();!c.done;c=l.next()){var u=c.value,p=this.getVariantChar(o,u)[3],h=p.f?" TEX-"+p.f:"",f=p.unknown?this.jax.unknownText(String.fromCodePoint(u),o):this.html("mjx-c",{class:this.char(u)+h});n.append(t,f),!p.unknown&&this.font.charUsage.add([o,u])}}catch(t){e={error:t}}finally{try{c&&!c.done&&(r=l.return)&&r.call(l)}finally{if(e)throw e.error}}}},e.kind=s.TextNode.prototype.kind,e.autoStyle=!1,e.styles={"mjx-c":{display:"inline-block"},"mjx-utext":{display:"inline-block",padding:".75em 0 .2em 0"}},e}((0,r(1160).CommonTextNodeMixin)(a.CHTMLWrapper));e.CHTMLTextNode=l},8102:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmaction=void 0;var i=r(5355),s=r(1956),a=r(1956),l=r(9145),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.standardCHTMLnode(t);this.selected.toCHTML(e),this.action(this,this.data)},e.prototype.setEventHandler=function(t,e){this.chtml.addEventListener(t,e)},e.kind=l.MmlMaction.prototype.kind,e.styles={"mjx-maction":{position:"relative"},"mjx-maction > mjx-tool":{display:"none",position:"absolute",bottom:0,right:0,width:0,height:0,"z-index":500},"mjx-tool > mjx-tip":{display:"inline-block",padding:".2em",border:"1px solid #888","font-size":"70%","background-color":"#F8F8F8",color:"black","box-shadow":"2px 2px 5px #AAAAAA"},"mjx-maction[toggle]":{cursor:"pointer"},"mjx-status":{display:"block",position:"fixed",left:"1em",bottom:"1em","min-width":"25%",padding:".2em .4em",border:"1px solid #888","font-size":"90%","background-color":"#F8F8F8",color:"black"}},e.actions=new Map([["toggle",[function(t,e){t.adaptor.setAttribute(t.chtml,"toggle",t.node.attributes.get("selection"));var r=t.factory.jax.math,n=t.factory.jax.document,o=t.node;t.setEventHandler("click",(function(t){r.end.node||(r.start.node=r.end.node=r.typesetRoot,r.start.n=r.end.n=0),o.nextToggleSelection(),r.rerender(n),t.stopPropagation()}))},{}]],["tooltip",[function(t,e){var r=t.childNodes[1];if(r)if(r.node.isKind("mtext")){var n=r.node.getText();t.adaptor.setAttribute(t.chtml,"title",n)}else{var o=t.adaptor,i=o.append(t.chtml,t.html("mjx-tool",{style:{bottom:t.em(-t.dy),right:t.em(-t.dx)}},[t.html("mjx-tip")]));r.toCHTML(o.firstChild(i)),t.setEventHandler("mouseover",(function(r){e.stopTimers(t,e);var n=setTimeout((function(){return o.setStyle(i,"display","block")}),e.postDelay);e.hoverTimer.set(t,n),r.stopPropagation()})),t.setEventHandler("mouseout",(function(r){e.stopTimers(t,e);var n=setTimeout((function(){return o.setStyle(i,"display","")}),e.clearDelay);e.clearTimer.set(t,n),r.stopPropagation()}))}},a.TooltipData]],["statusline",[function(t,e){var r=t.childNodes[1];if(r&&r.node.isKind("mtext")){var n=t.adaptor,o=r.node.getText();n.setAttribute(t.chtml,"statusline",o),t.setEventHandler("mouseover",(function(r){if(null===e.status){var i=n.body(n.document);e.status=n.append(i,t.html("mjx-status",{},[t.text(o)]))}r.stopPropagation()})),t.setEventHandler("mouseout",(function(t){e.status&&(n.remove(e.status),e.status=null),t.stopPropagation()}))}},{status:null}]]]),e}((0,s.CommonMactionMixin)(i.CHTMLWrapper));e.CHTMLmaction=c},804:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmath=void 0;var s=r(5355),a=r(7490),l=r(3233),c=r(6469),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){t.prototype.toCHTML.call(this,e);var r=this.chtml,n=this.adaptor;"block"===this.node.attributes.get("display")?(n.setAttribute(r,"display","true"),n.setAttribute(e,"display","true"),this.handleDisplay(e)):this.handleInline(e),n.addClass(r,"MJX-TEX")},e.prototype.handleDisplay=function(t){var e=this.adaptor,r=i(this.getAlignShift(),2),n=r[0],o=r[1];if("center"!==n&&e.setAttribute(t,"justify",n),this.bbox.pwidth===c.BBox.fullWidth){if(e.setAttribute(t,"width","full"),this.jax.table){var s=this.jax.table.getOuterBBox(),a=s.L,l=s.w,u=s.R;"right"===n?u=Math.max(u||-o,-o):"left"===n?a=Math.max(a||o,o):"center"===n&&(l+=2*Math.abs(o));var p=this.em(Math.max(0,a+l+u));e.setStyle(t,"min-width",p),e.setStyle(this.jax.table.chtml,"min-width",p)}}else this.setIndent(this.chtml,n,o)},e.prototype.handleInline=function(t){var e=this.adaptor,r=e.getStyle(this.chtml,"margin-right");r&&(e.setStyle(this.chtml,"margin-right",""),e.setStyle(t,"margin-right",r),e.setStyle(t,"width","0"))},e.prototype.setChildPWidths=function(e,r,n){return void 0===r&&(r=null),void 0===n&&(n=!0),!!this.parent&&t.prototype.setChildPWidths.call(this,e,r,n)},e.kind=l.MmlMath.prototype.kind,e.styles={"mjx-math":{"line-height":0,"text-align":"left","text-indent":0,"font-style":"normal","font-weight":"normal","font-size":"100%","font-size-adjust":"none","letter-spacing":"normal","border-collapse":"collapse","word-wrap":"normal","word-spacing":"normal","white-space":"nowrap",direction:"ltr",padding:"1px 0"},'mjx-container[jax="CHTML"][display="true"]':{display:"block","text-align":"center",margin:"1em 0"},'mjx-container[jax="CHTML"][display="true"][width="full"]':{display:"flex"},'mjx-container[jax="CHTML"][display="true"] mjx-math':{padding:0},'mjx-container[jax="CHTML"][justify="left"]':{"text-align":"left"},'mjx-container[jax="CHTML"][justify="right"]':{"text-align":"right"}},e}((0,a.CommonMathMixin)(s.CHTMLWrapper));e.CHTMLmath=u},8147:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__createBinding||(Object.create?function(t,e,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(e,r);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[r]}}),Object.defineProperty(t,n,o)}:function(t,e,r,n){void 0===n&&(n=r),t[n]=e[r]}),s=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),a=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var r in t)"default"!==r&&Object.prototype.hasOwnProperty.call(t,r)&&i(e,t,r);return s(e,t),e},l=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},c=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmenclose=void 0;var u=r(5355),p=r(7313),h=a(r(8270)),f=r(6661),d=r(6010);function m(t,e){return Math.atan2(t,e).toFixed(3).replace(/\.?0+$/,"")}var y=m(h.ARROWDX,h.ARROWY),g=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r,n,o,i=this.adaptor,s=this.standardCHTMLnode(t),a=i.append(s,this.html("mjx-box"));this.renderChild?this.renderChild(this,a):this.childNodes[0].toCHTML(a);try{for(var c=l(Object.keys(this.notations)),u=c.next();!u.done;u=c.next()){var p=u.value,f=this.notations[p];!f.renderChild&&f.renderer(this,a)}}catch(t){e={error:t}}finally{try{u&&!u.done&&(r=c.return)&&r.call(c)}finally{if(e)throw e.error}}var d=this.getPadding();try{for(var m=l(h.sideNames),y=m.next();!y.done;y=m.next()){var g=y.value,b=h.sideIndex[g];d[b]>0&&i.setStyle(a,"padding-"+g,this.em(d[b]))}}catch(t){n={error:t}}finally{try{y&&!y.done&&(o=m.return)&&o.call(m)}finally{if(n)throw n.error}}},e.prototype.arrow=function(t,e,r,n,o){void 0===n&&(n=""),void 0===o&&(o=0);var i=this.getBBox().w,s={width:this.em(t)};i!==t&&(s.left=this.em((i-t)/2)),e&&(s.transform="rotate("+this.fixed(e)+"rad)");var a=this.html("mjx-arrow",{style:s},[this.html("mjx-aline"),this.html("mjx-rthead"),this.html("mjx-rbhead")]);return r&&(this.adaptor.append(a,this.html("mjx-lthead")),this.adaptor.append(a,this.html("mjx-lbhead")),this.adaptor.setAttribute(a,"double","true")),this.adjustArrow(a,r),this.moveArrow(a,n,o),a},e.prototype.adjustArrow=function(t,e){var r=this,n=this.thickness,o=this.arrowhead;if(o.x!==h.ARROWX||o.y!==h.ARROWY||o.dx!==h.ARROWDX||n!==h.THICKNESS){var i=c([n*o.x,n*o.y].map((function(t){return r.em(t)})),2),s=i[0],a=i[1],l=m(o.dx,o.y),u=c(this.adaptor.childNodes(t),5),p=u[0],f=u[1],d=u[2],y=u[3],g=u[4];this.adjustHead(f,[a,"0","1px",s],l),this.adjustHead(d,["1px","0",a,s],"-"+l),this.adjustHead(y,[a,s,"1px","0"],"-"+l),this.adjustHead(g,["1px",s,a,"0"],l),this.adjustLine(p,n,o.x,e)}},e.prototype.adjustHead=function(t,e,r){t&&(this.adaptor.setStyle(t,"border-width",e.join(" ")),this.adaptor.setStyle(t,"transform","skewX("+r+"rad)"))},e.prototype.adjustLine=function(t,e,r,n){this.adaptor.setStyle(t,"borderTop",this.em(e)+" solid"),this.adaptor.setStyle(t,"top",this.em(-e/2)),this.adaptor.setStyle(t,"right",this.em(e*(r-1))),n&&this.adaptor.setStyle(t,"left",this.em(e*(r-1)))},e.prototype.moveArrow=function(t,e,r){if(r){var n=this.adaptor.getStyle(t,"transform");this.adaptor.setStyle(t,"transform","translate".concat(e,"(").concat(this.em(-r),")").concat(n?" "+n:""))}},e.prototype.adjustBorder=function(t){return this.thickness!==h.THICKNESS&&this.adaptor.setStyle(t,"borderWidth",this.em(this.thickness)),t},e.prototype.adjustThickness=function(t){return this.thickness!==h.THICKNESS&&this.adaptor.setStyle(t,"strokeWidth",this.fixed(this.thickness)),t},e.prototype.fixed=function(t,e){return void 0===e&&(e=3),Math.abs(t)<6e-4?"0":t.toFixed(e).replace(/\.?0+$/,"")},e.prototype.em=function(e){return t.prototype.em.call(this,e)},e.kind=f.MmlMenclose.prototype.kind,e.styles={"mjx-menclose":{position:"relative"},"mjx-menclose > mjx-dstrike":{display:"inline-block",left:0,top:0,position:"absolute","border-top":h.SOLID,"transform-origin":"top left"},"mjx-menclose > mjx-ustrike":{display:"inline-block",left:0,bottom:0,position:"absolute","border-top":h.SOLID,"transform-origin":"bottom left"},"mjx-menclose > mjx-hstrike":{"border-top":h.SOLID,position:"absolute",left:0,right:0,bottom:"50%",transform:"translateY("+(0,d.em)(h.THICKNESS/2)+")"},"mjx-menclose > mjx-vstrike":{"border-left":h.SOLID,position:"absolute",top:0,bottom:0,right:"50%",transform:"translateX("+(0,d.em)(h.THICKNESS/2)+")"},"mjx-menclose > mjx-rbox":{position:"absolute",top:0,bottom:0,right:0,left:0,border:h.SOLID,"border-radius":(0,d.em)(h.THICKNESS+h.PADDING)},"mjx-menclose > mjx-cbox":{position:"absolute",top:0,bottom:0,right:0,left:0,border:h.SOLID,"border-radius":"50%"},"mjx-menclose > mjx-arrow":{position:"absolute",left:0,bottom:"50%",height:0,width:0},"mjx-menclose > mjx-arrow > *":{display:"block",position:"absolute","transform-origin":"bottom","border-left":(0,d.em)(h.THICKNESS*h.ARROWX)+" solid","border-right":0,"box-sizing":"border-box"},"mjx-menclose > mjx-arrow > mjx-aline":{left:0,top:(0,d.em)(-h.THICKNESS/2),right:(0,d.em)(h.THICKNESS*(h.ARROWX-1)),height:0,"border-top":(0,d.em)(h.THICKNESS)+" solid","border-left":0},"mjx-menclose > mjx-arrow[double] > mjx-aline":{left:(0,d.em)(h.THICKNESS*(h.ARROWX-1)),height:0},"mjx-menclose > mjx-arrow > mjx-rthead":{transform:"skewX("+y+"rad)",right:0,bottom:"-1px","border-bottom":"1px solid transparent","border-top":(0,d.em)(h.THICKNESS*h.ARROWY)+" solid transparent"},"mjx-menclose > mjx-arrow > mjx-rbhead":{transform:"skewX(-"+y+"rad)","transform-origin":"top",right:0,top:"-1px","border-top":"1px solid transparent","border-bottom":(0,d.em)(h.THICKNESS*h.ARROWY)+" solid transparent"},"mjx-menclose > mjx-arrow > mjx-lthead":{transform:"skewX(-"+y+"rad)",left:0,bottom:"-1px","border-left":0,"border-right":(0,d.em)(h.THICKNESS*h.ARROWX)+" solid","border-bottom":"1px solid transparent","border-top":(0,d.em)(h.THICKNESS*h.ARROWY)+" solid transparent"},"mjx-menclose > mjx-arrow > mjx-lbhead":{transform:"skewX("+y+"rad)","transform-origin":"top",left:0,top:"-1px","border-left":0,"border-right":(0,d.em)(h.THICKNESS*h.ARROWX)+" solid","border-top":"1px solid transparent","border-bottom":(0,d.em)(h.THICKNESS*h.ARROWY)+" solid transparent"},"mjx-menclose > dbox":{position:"absolute",top:0,bottom:0,left:(0,d.em)(-1.5*h.PADDING),width:(0,d.em)(3*h.PADDING),border:(0,d.em)(h.THICKNESS)+" solid","border-radius":"50%","clip-path":"inset(0 0 0 "+(0,d.em)(1.5*h.PADDING)+")","box-sizing":"border-box"}},e.notations=new Map([h.Border("top"),h.Border("right"),h.Border("bottom"),h.Border("left"),h.Border2("actuarial","top","right"),h.Border2("madruwb","bottom","right"),h.DiagonalStrike("up",1),h.DiagonalStrike("down",-1),["horizontalstrike",{renderer:h.RenderElement("hstrike","Y"),bbox:function(t){return[0,t.padding,0,t.padding]}}],["verticalstrike",{renderer:h.RenderElement("vstrike","X"),bbox:function(t){return[t.padding,0,t.padding,0]}}],["box",{renderer:function(t,e){t.adaptor.setStyle(e,"border",t.em(t.thickness)+" solid")},bbox:h.fullBBox,border:h.fullBorder,remove:"left right top bottom"}],["roundedbox",{renderer:h.RenderElement("rbox"),bbox:h.fullBBox}],["circle",{renderer:h.RenderElement("cbox"),bbox:h.fullBBox}],["phasorangle",{renderer:function(t,e){var r=t.getBBox(),n=r.h,o=r.d,i=c(t.getArgMod(1.75*t.padding,n+o),2),s=i[0],a=i[1],l=t.thickness*Math.sin(s)*.9;t.adaptor.setStyle(e,"border-bottom",t.em(t.thickness)+" solid");var u=t.adjustBorder(t.html("mjx-ustrike",{style:{width:t.em(a),transform:"translateX("+t.em(l)+") rotate("+t.fixed(-s)+"rad)"}}));t.adaptor.append(t.chtml,u)},bbox:function(t){var e=t.padding/2,r=t.thickness;return[2*e,e,e+r,3*e+r]},border:function(t){return[0,0,t.thickness,0]},remove:"bottom"}],h.Arrow("up"),h.Arrow("down"),h.Arrow("left"),h.Arrow("right"),h.Arrow("updown"),h.Arrow("leftright"),h.DiagonalArrow("updiagonal"),h.DiagonalArrow("northeast"),h.DiagonalArrow("southeast"),h.DiagonalArrow("northwest"),h.DiagonalArrow("southwest"),h.DiagonalArrow("northeastsouthwest"),h.DiagonalArrow("northwestsoutheast"),["longdiv",{renderer:function(t,e){var r=t.adaptor;r.setStyle(e,"border-top",t.em(t.thickness)+" solid");var n=r.append(t.chtml,t.html("dbox")),o=t.thickness,i=t.padding;o!==h.THICKNESS&&r.setStyle(n,"border-width",t.em(o)),i!==h.PADDING&&(r.setStyle(n,"left",t.em(-1.5*i)),r.setStyle(n,"width",t.em(3*i)),r.setStyle(n,"clip-path","inset(0 0 0 "+t.em(1.5*i)+")"))},bbox:function(t){var e=t.padding,r=t.thickness;return[e+r,e,e,2*e+r/2]}}],["radical",{renderer:function(t,e){t.msqrt.toCHTML(e);var r=t.sqrtTRBL();t.adaptor.setStyle(t.msqrt.chtml,"margin",r.map((function(e){return t.em(-e)})).join(" "))},init:function(t){t.msqrt=t.createMsqrt(t.childNodes[0])},bbox:function(t){return t.sqrtTRBL()},renderChild:!0}]]),e}((0,p.CommonMencloseMixin)(u.CHTMLWrapper));e.CHTMLmenclose=g},2275:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmfenced=void 0;var i=r(5355),s=r(7555),a=r(5410),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.standardCHTMLnode(t);this.mrow.toCHTML(e)},e.kind=a.MmlMfenced.prototype.kind,e}((0,s.CommonMfencedMixin)(i.CHTMLWrapper));e.CHTMLmfenced=l},9063:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r *":{"font-size":"2000%"},"mjx-dbox":{display:"block","font-size":"5%"},"mjx-num":{display:"block","text-align":"center"},"mjx-den":{display:"block","text-align":"center"},"mjx-mfrac[bevelled] > mjx-num":{display:"inline-block"},"mjx-mfrac[bevelled] > mjx-den":{display:"inline-block"},'mjx-den[align="right"], mjx-num[align="right"]':{"text-align":"right"},'mjx-den[align="left"], mjx-num[align="left"]':{"text-align":"left"},"mjx-nstrut":{display:"inline-block",height:".054em",width:0,"vertical-align":"-.054em"},'mjx-nstrut[type="d"]':{height:".217em","vertical-align":"-.217em"},"mjx-dstrut":{display:"inline-block",height:".505em",width:0},'mjx-dstrut[type="d"]':{height:".726em"},"mjx-line":{display:"block","box-sizing":"border-box","min-height":"1px",height:".06em","border-top":".06em solid",margin:".06em -.1em",overflow:"hidden"},'mjx-line[type="d"]':{margin:".18em -.1em"}},e}((0,a.CommonMfracMixin)(s.CHTMLWrapper));e.CHTMLmfrac=c},6911:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmglyph=void 0;var i=r(5355),s=r(5636),a=r(3985),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.standardCHTMLnode(t);if(this.charWrapper)this.charWrapper.toCHTML(e);else{var r=this.node.attributes.getList("src","alt"),n=r.src,o=r.alt,i={width:this.em(this.width),height:this.em(this.height)};this.valign&&(i.verticalAlign=this.em(this.valign));var s=this.html("img",{src:n,style:i,alt:o,title:o});this.adaptor.append(e,s)}},e.kind=a.MmlMglyph.prototype.kind,e.styles={"mjx-mglyph > img":{display:"inline-block",border:0,padding:0}},e}((0,s.CommonMglyphMixin)(i.CHTMLWrapper));e.CHTMLmglyph=l},1653:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmi=void 0;var i=r(5355),s=r(5723),a=r(450),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=a.MmlMi.prototype.kind,e}((0,s.CommonMiMixin)(i.CHTMLWrapper));e.CHTMLmi=l},6781:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmmultiscripts=void 0;var s=r(4300),a=r(8009),l=r(6405),c=r(505),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.standardCHTMLnode(t),r=this.scriptData,n=this.node.getProperty("scriptalign")||"right left",o=i((0,c.split)(n+" "+n),2),s=o[0],a=o[1],l=this.combinePrePost(r.sub,r.psub),u=this.combinePrePost(r.sup,r.psup),p=i(this.getUVQ(l,u),2),h=p[0],f=p[1];if(r.numPrescripts){var d=this.addScripts(h,-f,!0,r.psub,r.psup,this.firstPrescript,r.numPrescripts);"right"!==s&&this.adaptor.setAttribute(d,"script-align",s)}if(this.childNodes[0].toCHTML(e),r.numScripts){d=this.addScripts(h,-f,!1,r.sub,r.sup,1,r.numScripts);"left"!==a&&this.adaptor.setAttribute(d,"script-align",a)}},e.prototype.addScripts=function(t,e,r,n,o,i,s){for(var a=this.adaptor,l=t-o.d+(e-n.h),c=t<0&&0===e?n.h+t:t,u=l>0?{style:{height:this.em(l)}}:{},p=c?{style:{"vertical-align":this.em(c)}}:{},h=this.html("mjx-row"),f=this.html("mjx-row",u),d=this.html("mjx-row"),m="mjx-"+(r?"pre":"")+"scripts",y=i+2*s;i mjx-row > mjx-cell":{"text-align":"right"},'[script-align="left"] > mjx-row > mjx-cell':{"text-align":"left"},'[script-align="center"] > mjx-row > mjx-cell':{"text-align":"center"},'[script-align="right"] > mjx-row > mjx-cell':{"text-align":"right"}},e}((0,a.CommonMmultiscriptsMixin)(s.CHTMLmsubsup));e.CHTMLmmultiscripts=u},6460:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmn=void 0;var i=r(5355),s=r(5023),a=r(3050),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=a.MmlMn.prototype.kind,e}((0,s.CommonMnMixin)(i.CHTMLWrapper));e.CHTMLmn=l},6287:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmo=void 0;var s=r(5355),a=r(7096),l=r(2756),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r,n=this.node.attributes,o=n.get("symmetric")&&2!==this.stretch.dir,s=0!==this.stretch.dir;s&&null===this.size&&this.getStretchedVariant([]);var a=this.standardCHTMLnode(t);if(s&&this.size<0)this.stretchHTML(a);else{if(o||n.get("largeop")){var l=this.em(this.getCenterOffset());"0"!==l&&this.adaptor.setStyle(a,"verticalAlign",l)}this.node.getProperty("mathaccent")&&(this.adaptor.setStyle(a,"width","0"),this.adaptor.setStyle(a,"margin-left",this.em(this.getAccentOffset())));try{for(var c=i(this.childNodes),u=c.next();!u.done;u=c.next()){u.value.toCHTML(a)}}catch(t){e={error:t}}finally{try{u&&!u.done&&(r=c.return)&&r.call(c)}finally{if(e)throw e.error}}}},e.prototype.stretchHTML=function(t){var e=this.getText().codePointAt(0);this.font.delimUsage.add(e),this.childNodes[0].markUsed();var r=this.stretch,n=r.stretch,o=[];n[0]&&o.push(this.html("mjx-beg",{},[this.html("mjx-c")])),o.push(this.html("mjx-ext",{},[this.html("mjx-c")])),4===n.length&&o.push(this.html("mjx-mid",{},[this.html("mjx-c")]),this.html("mjx-ext",{},[this.html("mjx-c")])),n[2]&&o.push(this.html("mjx-end",{},[this.html("mjx-c")]));var i={},s=this.bbox,l=s.h,c=s.d,u=s.w;1===r.dir?(o.push(this.html("mjx-mark")),i.height=this.em(l+c),i.verticalAlign=this.em(-c)):i.width=this.em(u);var p=a.DirectionVH[r.dir],h={class:this.char(r.c||e),style:i},f=this.html("mjx-stretchy-"+p,h,o);this.adaptor.append(t,f)},e.kind=l.MmlMo.prototype.kind,e.styles={"mjx-stretchy-h":{display:"inline-table",width:"100%"},"mjx-stretchy-h > *":{display:"table-cell",width:0},"mjx-stretchy-h > * > mjx-c":{display:"inline-block",transform:"scalex(1.0000001)"},"mjx-stretchy-h > * > mjx-c::before":{display:"inline-block",width:"initial"},"mjx-stretchy-h > mjx-ext":{"/* IE */ overflow":"hidden","/* others */ overflow":"clip visible",width:"100%"},"mjx-stretchy-h > mjx-ext > mjx-c::before":{transform:"scalex(500)"},"mjx-stretchy-h > mjx-ext > mjx-c":{width:0},"mjx-stretchy-h > mjx-beg > mjx-c":{"margin-right":"-.1em"},"mjx-stretchy-h > mjx-end > mjx-c":{"margin-left":"-.1em"},"mjx-stretchy-v":{display:"inline-block"},"mjx-stretchy-v > *":{display:"block"},"mjx-stretchy-v > mjx-beg":{height:0},"mjx-stretchy-v > mjx-end > mjx-c":{display:"block"},"mjx-stretchy-v > * > mjx-c":{transform:"scaley(1.0000001)","transform-origin":"left center",overflow:"hidden"},"mjx-stretchy-v > mjx-ext":{display:"block",height:"100%","box-sizing":"border-box",border:"0px solid transparent","/* IE */ overflow":"hidden","/* others */ overflow":"visible clip"},"mjx-stretchy-v > mjx-ext > mjx-c::before":{width:"initial","box-sizing":"border-box"},"mjx-stretchy-v > mjx-ext > mjx-c":{transform:"scaleY(500) translateY(.075em)",overflow:"visible"},"mjx-mark":{display:"inline-block",height:"0px"}},e}((0,a.CommonMoMixin)(s.CHTMLWrapper));e.CHTMLmo=c},5964:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmpadded=void 0;var a=r(5355),l=r(6898),c=r(7238),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r,n=this.standardCHTMLnode(t),o=[],a={},l=i(this.getDimens(),9),c=l[2],u=l[3],p=l[4],h=l[5],f=l[6],d=l[7],m=l[8];if(h&&(a.width=this.em(c+h)),(u||p)&&(a.margin=this.em(u)+" 0 "+this.em(p)),f+m||d){a.position="relative";var y=this.html("mjx-rbox",{style:{left:this.em(f+m),top:this.em(-d),"max-width":a.width}});f+m&&this.childNodes[0].getBBox().pwidth&&(this.adaptor.setAttribute(y,"width","full"),this.adaptor.setStyle(y,"left",this.em(f))),o.push(y)}n=this.adaptor.append(n,this.html("mjx-block",{style:a},o));try{for(var g=s(this.childNodes),b=g.next();!b.done;b=g.next()){b.value.toCHTML(o[0]||n)}}catch(t){e={error:t}}finally{try{b&&!b.done&&(r=g.return)&&r.call(g)}finally{if(e)throw e.error}}},e.kind=c.MmlMpadded.prototype.kind,e.styles={"mjx-mpadded":{display:"inline-block"},"mjx-rbox":{display:"inline-block",position:"relative"}},e}((0,l.CommonMpaddedMixin)(a.CHTMLWrapper));e.CHTMLmpadded=u},8776:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmroot=void 0;var s=r(5610),a=r(6991),l=r(6145),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.addRoot=function(t,e,r,n){e.toCHTML(t);var o=i(this.getRootDimens(r,n),3),s=o[0],a=o[1],l=o[2];this.adaptor.setStyle(t,"verticalAlign",this.em(a)),this.adaptor.setStyle(t,"width",this.em(s)),l&&this.adaptor.setStyle(this.adaptor.firstChild(t),"paddingLeft",this.em(l))},e.kind=l.MmlMroot.prototype.kind,e}((0,a.CommonMrootMixin)(s.CHTMLmsqrt));e.CHTMLmroot=c},4798:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLinferredMrow=e.CHTMLmrow=void 0;var s=r(5355),a=r(8411),l=r(8411),c=r(9878),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r,n=this.node.isInferred?this.chtml=t:this.standardCHTMLnode(t),o=!1;try{for(var s=i(this.childNodes),a=s.next();!a.done;a=s.next()){var l=a.value;l.toCHTML(n),l.bbox.w<0&&(o=!0)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}if(o){var c=this.getBBox().w;c&&(this.adaptor.setStyle(n,"width",this.em(Math.max(0,c))),c<0&&this.adaptor.setStyle(n,"marginRight",this.em(c)))}},e.kind=c.MmlMrow.prototype.kind,e}((0,a.CommonMrowMixin)(s.CHTMLWrapper));e.CHTMLmrow=u;var p=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=c.MmlInferredMrow.prototype.kind,e}((0,l.CommonInferredMrowMixin)(u));e.CHTMLinferredMrow=p},4597:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLms=void 0;var i=r(5355),s=r(4126),a=r(7265),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=a.MmlMs.prototype.kind,e}((0,s.CommonMsMixin)(i.CHTMLWrapper));e.CHTMLms=l},2970:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmspace=void 0;var i=r(5355),s=r(258),a=r(6030),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.standardCHTMLnode(t),r=this.getBBox(),n=r.w,o=r.h,i=r.d;n<0&&(this.adaptor.setStyle(e,"marginRight",this.em(n)),n=0),n&&this.adaptor.setStyle(e,"width",this.em(n)),(o=Math.max(0,o+i))&&this.adaptor.setStyle(e,"height",this.em(Math.max(0,o))),i&&this.adaptor.setStyle(e,"verticalAlign",this.em(-i))},e.kind=a.MmlMspace.prototype.kind,e}((0,s.CommonMspaceMixin)(i.CHTMLWrapper));e.CHTMLmspace=l},5610:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmsqrt=void 0;var s=r(5355),a=r(4093),l=r(7131),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e,r,n,o,s=this.childNodes[this.surd],a=this.childNodes[this.base],l=s.getBBox(),c=a.getOuterBBox(),u=i(this.getPQ(l),2)[1],p=this.font.params.rule_thickness,h=c.h+u+p,f=this.standardCHTMLnode(t);null!=this.root&&(n=this.adaptor.append(f,this.html("mjx-root")),o=this.childNodes[this.root]);var d=this.adaptor.append(f,this.html("mjx-sqrt",{},[e=this.html("mjx-surd"),r=this.html("mjx-box",{style:{paddingTop:this.em(u)}})]));this.addRoot(n,o,l,h),s.toCHTML(e),a.toCHTML(r),s.size<0&&this.adaptor.addClass(d,"mjx-tall")},e.prototype.addRoot=function(t,e,r,n){},e.kind=l.MmlMsqrt.prototype.kind,e.styles={"mjx-root":{display:"inline-block","white-space":"nowrap"},"mjx-surd":{display:"inline-block","vertical-align":"top"},"mjx-sqrt":{display:"inline-block","padding-top":".07em"},"mjx-sqrt > mjx-box":{"border-top":".07em solid"},"mjx-sqrt.mjx-tall > mjx-box":{"padding-left":".3em","margin-left":"-.3em"}},e}((0,a.CommonMsqrtMixin)(s.CHTMLWrapper));e.CHTMLmsqrt=c},4300:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmsubsup=e.CHTMLmsup=e.CHTMLmsub=void 0;var s=r(8650),a=r(905),l=r(905),c=r(905),u=r(4461),p=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=u.MmlMsub.prototype.kind,e}((0,a.CommonMsubMixin)(s.CHTMLscriptbase));e.CHTMLmsub=p;var h=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=u.MmlMsup.prototype.kind,e}((0,l.CommonMsupMixin)(s.CHTMLscriptbase));e.CHTMLmsup=h;var f=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){var e=this.adaptor,r=this.standardCHTMLnode(t),n=i([this.baseChild,this.supChild,this.subChild],3),o=n[0],s=n[1],a=n[2],l=i(this.getUVQ(),3),c=l[1],u=l[2],p={"vertical-align":this.em(c)};o.toCHTML(r);var h=e.append(r,this.html("mjx-script",{style:p}));s.toCHTML(h),e.append(h,this.html("mjx-spacer",{style:{"margin-top":this.em(u)}})),a.toCHTML(h);var f=this.getAdjustedIc();f&&e.setStyle(s.chtml,"marginLeft",this.em(f/s.bbox.rscale)),this.baseRemoveIc&&e.setStyle(h,"marginLeft",this.em(-this.baseIc))},e.kind=u.MmlMsubsup.prototype.kind,e.styles={"mjx-script":{display:"inline-block","padding-right":".05em","padding-left":".033em"},"mjx-script > mjx-spacer":{display:"block"}},e}((0,c.CommonMsubsupMixin)(s.CHTMLscriptbase));e.CHTMLmsubsup=f},8002:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmtable=void 0;var a=r(5355),l=r(6237),c=r(1349),u=r(505),p=function(t){function e(e,r,n){void 0===n&&(n=null);var o=t.call(this,e,r,n)||this;return o.itable=o.html("mjx-itable"),o.labels=o.html("mjx-itable"),o}return o(e,t),e.prototype.getAlignShift=function(){var e=t.prototype.getAlignShift.call(this);return this.isTop||(e[1]=0),e},e.prototype.toCHTML=function(t){var e,r,n=this.standardCHTMLnode(t);this.adaptor.append(n,this.html("mjx-table",{},[this.itable]));try{for(var o=i(this.childNodes),s=o.next();!s.done;s=o.next()){s.value.toCHTML(this.itable)}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}this.padRows(),this.handleColumnSpacing(),this.handleColumnLines(),this.handleColumnWidths(),this.handleRowSpacing(),this.handleRowLines(),this.handleRowHeights(),this.handleFrame(),this.handleWidth(),this.handleLabels(),this.handleAlign(),this.handleJustify(),this.shiftColor()},e.prototype.shiftColor=function(){var t=this.adaptor,e=t.getStyle(this.chtml,"backgroundColor");e&&(t.setStyle(this.chtml,"backgroundColor",""),t.setStyle(this.itable,"backgroundColor",e))},e.prototype.padRows=function(){var t,e,r=this.adaptor;try{for(var n=i(r.childNodes(this.itable)),o=n.next();!o.done;o=n.next())for(var s=o.value;r.childNodes(s).length1&&"0.4em"!==m||a&&1===p)&&this.adaptor.setStyle(g,"paddingLeft",m),(p1&&"0.215em"!==h||a&&1===l)&&this.adaptor.setStyle(y.chtml,"paddingTop",h),(l mjx-itable":{"vertical-align":"middle","text-align":"left","box-sizing":"border-box"},"mjx-labels > mjx-itable":{position:"absolute",top:0},'mjx-mtable[justify="left"]':{"text-align":"left"},'mjx-mtable[justify="right"]':{"text-align":"right"},'mjx-mtable[justify="left"][side="left"]':{"padding-right":"0 ! important"},'mjx-mtable[justify="left"][side="right"]':{"padding-left":"0 ! important"},'mjx-mtable[justify="right"][side="left"]':{"padding-right":"0 ! important"},'mjx-mtable[justify="right"][side="right"]':{"padding-left":"0 ! important"},"mjx-mtable[align]":{"vertical-align":"baseline"},'mjx-mtable[align="top"] > mjx-table':{"vertical-align":"top"},'mjx-mtable[align="bottom"] > mjx-table':{"vertical-align":"bottom"},'mjx-mtable[side="right"] mjx-labels':{"min-width":"100%"}},e}((0,l.CommonMtableMixin)(a.CHTMLWrapper));e.CHTMLmtable=p},7056:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmtd=void 0;var i=r(5355),s=r(5164),a=r(4359),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){t.prototype.toCHTML.call(this,e);var r=this.node.attributes.get("rowalign"),n=this.node.attributes.get("columnalign");r!==this.parent.node.attributes.get("rowalign")&&this.adaptor.setAttribute(this.chtml,"rowalign",r),"center"===n||"mlabeledtr"===this.parent.kind&&this===this.parent.childNodes[0]&&n===this.parent.parent.node.attributes.get("side")||this.adaptor.setStyle(this.chtml,"textAlign",n),this.parent.parent.node.getProperty("useHeight")&&this.adaptor.append(this.chtml,this.html("mjx-tstrut"))},e.kind=a.MmlMtd.prototype.kind,e.styles={"mjx-mtd":{display:"table-cell","text-align":"center",padding:".215em .4em"},"mjx-mtd:first-child":{"padding-left":0},"mjx-mtd:last-child":{"padding-right":0},"mjx-mtable > * > mjx-itable > *:first-child > mjx-mtd":{"padding-top":0},"mjx-mtable > * > mjx-itable > *:last-child > mjx-mtd":{"padding-bottom":0},"mjx-tstrut":{display:"inline-block",height:"1em","vertical-align":"-.25em"},'mjx-labels[align="left"] > mjx-mtr > mjx-mtd':{"text-align":"left"},'mjx-labels[align="right"] > mjx-mtr > mjx-mtd':{"text-align":"right"},"mjx-mtd[extra]":{padding:0},'mjx-mtd[rowalign="top"]':{"vertical-align":"top"},'mjx-mtd[rowalign="center"]':{"vertical-align":"middle"},'mjx-mtd[rowalign="bottom"]':{"vertical-align":"bottom"},'mjx-mtd[rowalign="baseline"]':{"vertical-align":"baseline"},'mjx-mtd[rowalign="axis"]':{"vertical-align":".25em"}},e}((0,s.CommonMtdMixin)(i.CHTMLWrapper));e.CHTMLmtd=l},1259:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmtext=void 0;var i=r(5355),s=r(6319),a=r(4770),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.kind=a.MmlMtext.prototype.kind,e}((0,s.CommonMtextMixin)(i.CHTMLWrapper));e.CHTMLmtext=l},3571:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmlabeledtr=e.CHTMLmtr=void 0;var i=r(5355),s=r(5766),a=r(5766),l=r(5022),c=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){t.prototype.toCHTML.call(this,e);var r=this.node.attributes.get("rowalign");"baseline"!==r&&this.adaptor.setAttribute(this.chtml,"rowalign",r)},e.kind=l.MmlMtr.prototype.kind,e.styles={"mjx-mtr":{display:"table-row"},'mjx-mtr[rowalign="top"] > mjx-mtd':{"vertical-align":"top"},'mjx-mtr[rowalign="center"] > mjx-mtd':{"vertical-align":"middle"},'mjx-mtr[rowalign="bottom"] > mjx-mtd':{"vertical-align":"bottom"},'mjx-mtr[rowalign="baseline"] > mjx-mtd':{"vertical-align":"baseline"},'mjx-mtr[rowalign="axis"] > mjx-mtd':{"vertical-align":".25em"}},e}((0,s.CommonMtrMixin)(i.CHTMLWrapper));e.CHTMLmtr=c;var u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){t.prototype.toCHTML.call(this,e);var r=this.adaptor.firstChild(this.chtml);if(r){this.adaptor.remove(r);var n=this.node.attributes.get("rowalign"),o="baseline"!==n&&"axis"!==n?{rowalign:n}:{},i=this.html("mjx-mtr",o,[r]);this.adaptor.append(this.parent.labels,i)}},e.prototype.markUsed=function(){t.prototype.markUsed.call(this),this.jax.wrapperUsage.add(c.kind)},e.kind=l.MmlMlabeledtr.prototype.kind,e.styles={"mjx-mlabeledtr":{display:"table-row"},'mjx-mlabeledtr[rowalign="top"] > mjx-mtd':{"vertical-align":"top"},'mjx-mlabeledtr[rowalign="center"] > mjx-mtd':{"vertical-align":"middle"},'mjx-mlabeledtr[rowalign="bottom"] > mjx-mtd':{"vertical-align":"bottom"},'mjx-mlabeledtr[rowalign="baseline"] > mjx-mtd':{"vertical-align":"baseline"},'mjx-mlabeledtr[rowalign="axis"] > mjx-mtd':{"vertical-align":".25em"}},e}((0,a.CommonMlabeledtrMixin)(c));e.CHTMLmlabeledtr=u},6590:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLmunderover=e.CHTMLmover=e.CHTMLmunder=void 0;var i=r(4300),s=r(1971),a=r(1971),l=r(1971),c=r(5184),u=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){if(this.hasMovableLimits())return t.prototype.toCHTML.call(this,e),void this.adaptor.setAttribute(this.chtml,"limits","false");this.chtml=this.standardCHTMLnode(e);var r=this.adaptor.append(this.adaptor.append(this.chtml,this.html("mjx-row")),this.html("mjx-base")),n=this.adaptor.append(this.adaptor.append(this.chtml,this.html("mjx-row")),this.html("mjx-under"));this.baseChild.toCHTML(r),this.scriptChild.toCHTML(n);var o=this.baseChild.getOuterBBox(),i=this.scriptChild.getOuterBBox(),s=this.getUnderKV(o,i)[0],a=this.isLineBelow?0:this.getDelta(!0);this.adaptor.setStyle(n,"paddingTop",this.em(s)),this.setDeltaW([r,n],this.getDeltaW([o,i],[0,-a])),this.adjustUnderDepth(n,i)},e.kind=c.MmlMunder.prototype.kind,e.styles={"mjx-over":{"text-align":"left"},'mjx-munder:not([limits="false"])':{display:"inline-table"},"mjx-munder > mjx-row":{"text-align":"left"},"mjx-under":{"padding-bottom":".1em"}},e}((0,s.CommonMunderMixin)(i.CHTMLmsub));e.CHTMLmunder=u;var p=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){if(this.hasMovableLimits())return t.prototype.toCHTML.call(this,e),void this.adaptor.setAttribute(this.chtml,"limits","false");this.chtml=this.standardCHTMLnode(e);var r=this.adaptor.append(this.chtml,this.html("mjx-over")),n=this.adaptor.append(this.chtml,this.html("mjx-base"));this.scriptChild.toCHTML(r),this.baseChild.toCHTML(n);var o=this.scriptChild.getOuterBBox(),i=this.baseChild.getOuterBBox();this.adjustBaseHeight(n,i);var s=this.getOverKU(i,o)[0],a=this.isLineAbove?0:this.getDelta();this.adaptor.setStyle(r,"paddingBottom",this.em(s)),this.setDeltaW([n,r],this.getDeltaW([i,o],[0,a])),this.adjustOverDepth(r,o)},e.kind=c.MmlMover.prototype.kind,e.styles={'mjx-mover:not([limits="false"])':{"padding-top":".1em"},'mjx-mover:not([limits="false"]) > *':{display:"block","text-align":"left"}},e}((0,a.CommonMoverMixin)(i.CHTMLmsup));e.CHTMLmover=p;var h=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(e){if(this.hasMovableLimits())return t.prototype.toCHTML.call(this,e),void this.adaptor.setAttribute(this.chtml,"limits","false");this.chtml=this.standardCHTMLnode(e);var r=this.adaptor.append(this.chtml,this.html("mjx-over")),n=this.adaptor.append(this.adaptor.append(this.chtml,this.html("mjx-box")),this.html("mjx-munder")),o=this.adaptor.append(this.adaptor.append(n,this.html("mjx-row")),this.html("mjx-base")),i=this.adaptor.append(this.adaptor.append(n,this.html("mjx-row")),this.html("mjx-under"));this.overChild.toCHTML(r),this.baseChild.toCHTML(o),this.underChild.toCHTML(i);var s=this.overChild.getOuterBBox(),a=this.baseChild.getOuterBBox(),l=this.underChild.getOuterBBox();this.adjustBaseHeight(o,a);var c=this.getOverKU(a,s)[0],u=this.getUnderKV(a,l)[0],p=this.getDelta();this.adaptor.setStyle(r,"paddingBottom",this.em(c)),this.adaptor.setStyle(i,"paddingTop",this.em(u)),this.setDeltaW([o,i,r],this.getDeltaW([a,l,s],[0,this.isLineBelow?0:-p,this.isLineAbove?0:p])),this.adjustOverDepth(r,s),this.adjustUnderDepth(i,l)},e.prototype.markUsed=function(){t.prototype.markUsed.call(this),this.jax.wrapperUsage.add(i.CHTMLmsubsup.kind)},e.kind=c.MmlMunderover.prototype.kind,e.styles={'mjx-munderover:not([limits="false"])':{"padding-top":".1em"},'mjx-munderover:not([limits="false"]) > *':{display:"block"}},e}((0,l.CommonMunderoverMixin)(i.CHTMLmsubsup));e.CHTMLmunderover=h},8650:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CHTMLscriptbase=void 0;var a=r(5355),l=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.toCHTML=function(t){this.chtml=this.standardCHTMLnode(t);var e=i(this.getOffset(),2),r=e[0],n=e[1],o=r-(this.baseRemoveIc?this.baseIc:0),s={"vertical-align":this.em(n)};o&&(s["margin-left"]=this.em(o)),this.baseChild.toCHTML(this.chtml),this.scriptChild.toCHTML(this.adaptor.append(this.chtml,this.html("mjx-script",{style:s})))},e.prototype.setDeltaW=function(t,e){for(var r=0;r=0||this.adaptor.setStyle(t,"marginBottom",this.em(e.d*e.rscale))},e.prototype.adjustUnderDepth=function(t,e){var r,n;if(!(e.d>=0)){var o=this.adaptor,i=this.em(e.d),a=this.html("mjx-box",{style:{"margin-bottom":i,"vertical-align":i}});try{for(var l=s(o.childNodes(o.firstChild(t))),c=l.next();!c.done;c=l.next()){var u=c.value;o.append(a,u)}}catch(t){r={error:t}}finally{try{c&&!c.done&&(n=l.return)&&n.call(l)}finally{if(r)throw r.error}}o.append(o.firstChild(t),a)}},e.prototype.adjustBaseHeight=function(t,e){if(this.node.attributes.get("accent")){var r=this.font.params.x_height*e.scale;e.h\\338"},8816:{c:"\\2264\\338"},8817:{c:"\\2265\\338"},8832:{c:"\\227A\\338"},8833:{c:"\\227B\\338"},8836:{c:"\\2282\\338"},8837:{c:"\\2283\\338"},8840:{c:"\\2286\\338"},8841:{c:"\\2287\\338"},8876:{c:"\\22A2\\338"},8877:{c:"\\22A8\\338"},8930:{c:"\\2291\\338"},8931:{c:"\\2292\\338"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},9653:{c:"\\25B3"},9663:{c:"\\25BD"},10072:{c:"\\2223"},10744:{c:"/",f:"BI"},10799:{c:"\\D7"},12296:{c:"\\27E8"},12297:{c:"\\27E9"}})},4515:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.doubleStruck=void 0;var n=r(6001);Object.defineProperty(e,"doubleStruck",{enumerable:!0,get:function(){return n.doubleStruck}})},6555:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.frakturBold=void 0;var n=r(8042),o=r(3696);e.frakturBold=(0,n.AddCSS)(o.frakturBold,{8260:{c:"/"}})},2183:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.fraktur=void 0;var n=r(8042),o=r(9587);e.fraktur=(0,n.AddCSS)(o.fraktur,{8260:{c:"/"}})},3490:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.italic=void 0;var n=r(8042),o=r(8348);e.italic=(0,n.AddCSS)(o.italic,{47:{f:"I"},989:{c:"\\E008",f:"A"},8213:{c:"\\2014"},8215:{c:"_"},8260:{c:"/",f:"I"},8710:{c:"\\394",f:"I"},10744:{c:"/",f:"I"}})},9056:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.largeop=void 0;var n=r(8042),o=r(1376);e.largeop=(0,n.AddCSS)(o.largeop,{8214:{f:"S1"},8260:{c:"/"},8593:{f:"S1"},8595:{f:"S1"},8657:{f:"S1"},8659:{f:"S1"},8739:{f:"S1"},8741:{f:"S1"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},9168:{f:"S1"},10072:{c:"\\2223",f:"S1"},10764:{c:"\\222C\\222C"},12296:{c:"\\27E8"},12297:{c:"\\27E9"}})},3019:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.monospace=void 0;var n=r(8042),o=r(1439);e.monospace=(0,n.AddCSS)(o.monospace,{697:{c:"\\2032"},913:{c:"A"},914:{c:"B"},917:{c:"E"},918:{c:"Z"},919:{c:"H"},921:{c:"I"},922:{c:"K"},924:{c:"M"},925:{c:"N"},927:{c:"O"},929:{c:"P"},932:{c:"T"},935:{c:"X"},8215:{c:"_"},8243:{c:"\\2032\\2032"},8244:{c:"\\2032\\2032\\2032"},8260:{c:"/"},8279:{c:"\\2032\\2032\\2032\\2032"},8710:{c:"\\394"}})},2713:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.normal=void 0;var n=r(8042),o=r(331);e.normal=(0,n.AddCSS)(o.normal,{163:{f:"MI"},165:{f:"A"},174:{f:"A"},183:{c:"\\22C5"},240:{f:"A"},697:{c:"\\2032"},913:{c:"A"},914:{c:"B"},917:{c:"E"},918:{c:"Z"},919:{c:"H"},921:{c:"I"},922:{c:"K"},924:{c:"M"},925:{c:"N"},927:{c:"O"},929:{c:"P"},932:{c:"T"},935:{c:"X"},8192:{c:""},8193:{c:""},8194:{c:""},8195:{c:""},8196:{c:""},8197:{c:""},8198:{c:""},8201:{c:""},8202:{c:""},8203:{c:""},8204:{c:""},8213:{c:"\\2014"},8214:{c:"\\2225"},8215:{c:"_"},8226:{c:"\\2219"},8243:{c:"\\2032\\2032"},8244:{c:"\\2032\\2032\\2032"},8245:{f:"A"},8246:{c:"\\2035\\2035",f:"A"},8247:{c:"\\2035\\2035\\2035",f:"A"},8254:{c:"\\2C9"},8260:{c:"/"},8279:{c:"\\2032\\2032\\2032\\2032"},8288:{c:""},8289:{c:""},8290:{c:""},8291:{c:""},8292:{c:""},8407:{c:"\\2192",f:"V"},8450:{c:"C",f:"A"},8459:{c:"H",f:"SC"},8460:{c:"H",f:"FR"},8461:{c:"H",f:"A"},8462:{c:"h",f:"I"},8463:{f:"A"},8464:{c:"I",f:"SC"},8465:{c:"I",f:"FR"},8466:{c:"L",f:"SC"},8469:{c:"N",f:"A"},8473:{c:"P",f:"A"},8474:{c:"Q",f:"A"},8475:{c:"R",f:"SC"},8476:{c:"R",f:"FR"},8477:{c:"R",f:"A"},8484:{c:"Z",f:"A"},8486:{c:"\\3A9"},8487:{f:"A"},8488:{c:"Z",f:"FR"},8492:{c:"B",f:"SC"},8493:{c:"C",f:"FR"},8496:{c:"E",f:"SC"},8497:{c:"F",f:"SC"},8498:{f:"A"},8499:{c:"M",f:"SC"},8502:{f:"A"},8503:{f:"A"},8504:{f:"A"},8513:{f:"A"},8602:{f:"A"},8603:{f:"A"},8606:{f:"A"},8608:{f:"A"},8610:{f:"A"},8611:{f:"A"},8619:{f:"A"},8620:{f:"A"},8621:{f:"A"},8622:{f:"A"},8624:{f:"A"},8625:{f:"A"},8630:{f:"A"},8631:{f:"A"},8634:{f:"A"},8635:{f:"A"},8638:{f:"A"},8639:{f:"A"},8642:{f:"A"},8643:{f:"A"},8644:{f:"A"},8646:{f:"A"},8647:{f:"A"},8648:{f:"A"},8649:{f:"A"},8650:{f:"A"},8651:{f:"A"},8653:{f:"A"},8654:{f:"A"},8655:{f:"A"},8666:{f:"A"},8667:{f:"A"},8669:{f:"A"},8672:{f:"A"},8674:{f:"A"},8705:{f:"A"},8708:{c:"\\2203\\338"},8710:{c:"\\394"},8716:{c:"\\220B\\338"},8717:{f:"A"},8719:{f:"S1"},8720:{f:"S1"},8721:{f:"S1"},8724:{f:"A"},8737:{f:"A"},8738:{f:"A"},8740:{f:"A"},8742:{f:"A"},8748:{f:"S1"},8749:{f:"S1"},8750:{f:"S1"},8756:{f:"A"},8757:{f:"A"},8765:{f:"A"},8769:{f:"A"},8770:{f:"A"},8772:{c:"\\2243\\338"},8775:{c:"\\2246",f:"A"},8777:{c:"\\2248\\338"},8778:{f:"A"},8782:{f:"A"},8783:{f:"A"},8785:{f:"A"},8786:{f:"A"},8787:{f:"A"},8790:{f:"A"},8791:{f:"A"},8796:{f:"A"},8802:{c:"\\2261\\338"},8806:{f:"A"},8807:{f:"A"},8808:{f:"A"},8809:{f:"A"},8812:{f:"A"},8813:{c:"\\224D\\338"},8814:{f:"A"},8815:{f:"A"},8816:{f:"A"},8817:{f:"A"},8818:{f:"A"},8819:{f:"A"},8820:{c:"\\2272\\338"},8821:{c:"\\2273\\338"},8822:{f:"A"},8823:{f:"A"},8824:{c:"\\2276\\338"},8825:{c:"\\2277\\338"},8828:{f:"A"},8829:{f:"A"},8830:{f:"A"},8831:{f:"A"},8832:{f:"A"},8833:{f:"A"},8836:{c:"\\2282\\338"},8837:{c:"\\2283\\338"},8840:{f:"A"},8841:{f:"A"},8842:{f:"A"},8843:{f:"A"},8847:{f:"A"},8848:{f:"A"},8858:{f:"A"},8859:{f:"A"},8861:{f:"A"},8862:{f:"A"},8863:{f:"A"},8864:{f:"A"},8865:{f:"A"},8873:{f:"A"},8874:{f:"A"},8876:{f:"A"},8877:{f:"A"},8878:{f:"A"},8879:{f:"A"},8882:{f:"A"},8883:{f:"A"},8884:{f:"A"},8885:{f:"A"},8888:{f:"A"},8890:{f:"A"},8891:{f:"A"},8892:{f:"A"},8896:{f:"S1"},8897:{f:"S1"},8898:{f:"S1"},8899:{f:"S1"},8903:{f:"A"},8905:{f:"A"},8906:{f:"A"},8907:{f:"A"},8908:{f:"A"},8909:{f:"A"},8910:{f:"A"},8911:{f:"A"},8912:{f:"A"},8913:{f:"A"},8914:{f:"A"},8915:{f:"A"},8916:{f:"A"},8918:{f:"A"},8919:{f:"A"},8920:{f:"A"},8921:{f:"A"},8922:{f:"A"},8923:{f:"A"},8926:{f:"A"},8927:{f:"A"},8928:{f:"A"},8929:{f:"A"},8930:{c:"\\2291\\338"},8931:{c:"\\2292\\338"},8934:{f:"A"},8935:{f:"A"},8936:{f:"A"},8937:{f:"A"},8938:{f:"A"},8939:{f:"A"},8940:{f:"A"},8941:{f:"A"},8965:{c:"\\22BC",f:"A"},8966:{c:"\\2A5E",f:"A"},8988:{c:"\\250C",f:"A"},8989:{c:"\\2510",f:"A"},8990:{c:"\\2514",f:"A"},8991:{c:"\\2518",f:"A"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},9168:{f:"S1"},9416:{f:"A"},9484:{f:"A"},9488:{f:"A"},9492:{f:"A"},9496:{f:"A"},9585:{f:"A"},9586:{f:"A"},9632:{f:"A"},9633:{f:"A"},9642:{c:"\\25A0",f:"A"},9650:{f:"A"},9652:{c:"\\25B2",f:"A"},9653:{c:"\\25B3"},9654:{f:"A"},9656:{c:"\\25B6",f:"A"},9660:{f:"A"},9662:{c:"\\25BC",f:"A"},9663:{c:"\\25BD"},9664:{f:"A"},9666:{c:"\\25C0",f:"A"},9674:{f:"A"},9723:{c:"\\25A1",f:"A"},9724:{c:"\\25A0",f:"A"},9733:{f:"A"},10003:{f:"A"},10016:{f:"A"},10072:{c:"\\2223"},10731:{f:"A"},10744:{c:"/",f:"I"},10752:{f:"S1"},10753:{f:"S1"},10754:{f:"S1"},10756:{f:"S1"},10758:{f:"S1"},10764:{c:"\\222C\\222C",f:"S1"},10799:{c:"\\D7"},10846:{f:"A"},10877:{f:"A"},10878:{f:"A"},10885:{f:"A"},10886:{f:"A"},10887:{f:"A"},10888:{f:"A"},10889:{f:"A"},10890:{f:"A"},10891:{f:"A"},10892:{f:"A"},10901:{f:"A"},10902:{f:"A"},10933:{f:"A"},10934:{f:"A"},10935:{f:"A"},10936:{f:"A"},10937:{f:"A"},10938:{f:"A"},10949:{f:"A"},10950:{f:"A"},10955:{f:"A"},10956:{f:"A"},12296:{c:"\\27E8"},12297:{c:"\\27E9"},57350:{f:"A"},57351:{f:"A"},57352:{f:"A"},57353:{f:"A"},57356:{f:"A"},57357:{f:"A"},57358:{f:"A"},57359:{f:"A"},57360:{f:"A"},57361:{f:"A"},57366:{f:"A"},57367:{f:"A"},57368:{f:"A"},57369:{f:"A"},57370:{f:"A"},57371:{f:"A"},119808:{c:"A",f:"B"},119809:{c:"B",f:"B"},119810:{c:"C",f:"B"},119811:{c:"D",f:"B"},119812:{c:"E",f:"B"},119813:{c:"F",f:"B"},119814:{c:"G",f:"B"},119815:{c:"H",f:"B"},119816:{c:"I",f:"B"},119817:{c:"J",f:"B"},119818:{c:"K",f:"B"},119819:{c:"L",f:"B"},119820:{c:"M",f:"B"},119821:{c:"N",f:"B"},119822:{c:"O",f:"B"},119823:{c:"P",f:"B"},119824:{c:"Q",f:"B"},119825:{c:"R",f:"B"},119826:{c:"S",f:"B"},119827:{c:"T",f:"B"},119828:{c:"U",f:"B"},119829:{c:"V",f:"B"},119830:{c:"W",f:"B"},119831:{c:"X",f:"B"},119832:{c:"Y",f:"B"},119833:{c:"Z",f:"B"},119834:{c:"a",f:"B"},119835:{c:"b",f:"B"},119836:{c:"c",f:"B"},119837:{c:"d",f:"B"},119838:{c:"e",f:"B"},119839:{c:"f",f:"B"},119840:{c:"g",f:"B"},119841:{c:"h",f:"B"},119842:{c:"i",f:"B"},119843:{c:"j",f:"B"},119844:{c:"k",f:"B"},119845:{c:"l",f:"B"},119846:{c:"m",f:"B"},119847:{c:"n",f:"B"},119848:{c:"o",f:"B"},119849:{c:"p",f:"B"},119850:{c:"q",f:"B"},119851:{c:"r",f:"B"},119852:{c:"s",f:"B"},119853:{c:"t",f:"B"},119854:{c:"u",f:"B"},119855:{c:"v",f:"B"},119856:{c:"w",f:"B"},119857:{c:"x",f:"B"},119858:{c:"y",f:"B"},119859:{c:"z",f:"B"},119860:{c:"A",f:"I"},119861:{c:"B",f:"I"},119862:{c:"C",f:"I"},119863:{c:"D",f:"I"},119864:{c:"E",f:"I"},119865:{c:"F",f:"I"},119866:{c:"G",f:"I"},119867:{c:"H",f:"I"},119868:{c:"I",f:"I"},119869:{c:"J",f:"I"},119870:{c:"K",f:"I"},119871:{c:"L",f:"I"},119872:{c:"M",f:"I"},119873:{c:"N",f:"I"},119874:{c:"O",f:"I"},119875:{c:"P",f:"I"},119876:{c:"Q",f:"I"},119877:{c:"R",f:"I"},119878:{c:"S",f:"I"},119879:{c:"T",f:"I"},119880:{c:"U",f:"I"},119881:{c:"V",f:"I"},119882:{c:"W",f:"I"},119883:{c:"X",f:"I"},119884:{c:"Y",f:"I"},119885:{c:"Z",f:"I"},119886:{c:"a",f:"I"},119887:{c:"b",f:"I"},119888:{c:"c",f:"I"},119889:{c:"d",f:"I"},119890:{c:"e",f:"I"},119891:{c:"f",f:"I"},119892:{c:"g",f:"I"},119894:{c:"i",f:"I"},119895:{c:"j",f:"I"},119896:{c:"k",f:"I"},119897:{c:"l",f:"I"},119898:{c:"m",f:"I"},119899:{c:"n",f:"I"},119900:{c:"o",f:"I"},119901:{c:"p",f:"I"},119902:{c:"q",f:"I"},119903:{c:"r",f:"I"},119904:{c:"s",f:"I"},119905:{c:"t",f:"I"},119906:{c:"u",f:"I"},119907:{c:"v",f:"I"},119908:{c:"w",f:"I"},119909:{c:"x",f:"I"},119910:{c:"y",f:"I"},119911:{c:"z",f:"I"},119912:{c:"A",f:"BI"},119913:{c:"B",f:"BI"},119914:{c:"C",f:"BI"},119915:{c:"D",f:"BI"},119916:{c:"E",f:"BI"},119917:{c:"F",f:"BI"},119918:{c:"G",f:"BI"},119919:{c:"H",f:"BI"},119920:{c:"I",f:"BI"},119921:{c:"J",f:"BI"},119922:{c:"K",f:"BI"},119923:{c:"L",f:"BI"},119924:{c:"M",f:"BI"},119925:{c:"N",f:"BI"},119926:{c:"O",f:"BI"},119927:{c:"P",f:"BI"},119928:{c:"Q",f:"BI"},119929:{c:"R",f:"BI"},119930:{c:"S",f:"BI"},119931:{c:"T",f:"BI"},119932:{c:"U",f:"BI"},119933:{c:"V",f:"BI"},119934:{c:"W",f:"BI"},119935:{c:"X",f:"BI"},119936:{c:"Y",f:"BI"},119937:{c:"Z",f:"BI"},119938:{c:"a",f:"BI"},119939:{c:"b",f:"BI"},119940:{c:"c",f:"BI"},119941:{c:"d",f:"BI"},119942:{c:"e",f:"BI"},119943:{c:"f",f:"BI"},119944:{c:"g",f:"BI"},119945:{c:"h",f:"BI"},119946:{c:"i",f:"BI"},119947:{c:"j",f:"BI"},119948:{c:"k",f:"BI"},119949:{c:"l",f:"BI"},119950:{c:"m",f:"BI"},119951:{c:"n",f:"BI"},119952:{c:"o",f:"BI"},119953:{c:"p",f:"BI"},119954:{c:"q",f:"BI"},119955:{c:"r",f:"BI"},119956:{c:"s",f:"BI"},119957:{c:"t",f:"BI"},119958:{c:"u",f:"BI"},119959:{c:"v",f:"BI"},119960:{c:"w",f:"BI"},119961:{c:"x",f:"BI"},119962:{c:"y",f:"BI"},119963:{c:"z",f:"BI"},119964:{c:"A",f:"SC"},119966:{c:"C",f:"SC"},119967:{c:"D",f:"SC"},119970:{c:"G",f:"SC"},119973:{c:"J",f:"SC"},119974:{c:"K",f:"SC"},119977:{c:"N",f:"SC"},119978:{c:"O",f:"SC"},119979:{c:"P",f:"SC"},119980:{c:"Q",f:"SC"},119982:{c:"S",f:"SC"},119983:{c:"T",f:"SC"},119984:{c:"U",f:"SC"},119985:{c:"V",f:"SC"},119986:{c:"W",f:"SC"},119987:{c:"X",f:"SC"},119988:{c:"Y",f:"SC"},119989:{c:"Z",f:"SC"},120068:{c:"A",f:"FR"},120069:{c:"B",f:"FR"},120071:{c:"D",f:"FR"},120072:{c:"E",f:"FR"},120073:{c:"F",f:"FR"},120074:{c:"G",f:"FR"},120077:{c:"J",f:"FR"},120078:{c:"K",f:"FR"},120079:{c:"L",f:"FR"},120080:{c:"M",f:"FR"},120081:{c:"N",f:"FR"},120082:{c:"O",f:"FR"},120083:{c:"P",f:"FR"},120084:{c:"Q",f:"FR"},120086:{c:"S",f:"FR"},120087:{c:"T",f:"FR"},120088:{c:"U",f:"FR"},120089:{c:"V",f:"FR"},120090:{c:"W",f:"FR"},120091:{c:"X",f:"FR"},120092:{c:"Y",f:"FR"},120094:{c:"a",f:"FR"},120095:{c:"b",f:"FR"},120096:{c:"c",f:"FR"},120097:{c:"d",f:"FR"},120098:{c:"e",f:"FR"},120099:{c:"f",f:"FR"},120100:{c:"g",f:"FR"},120101:{c:"h",f:"FR"},120102:{c:"i",f:"FR"},120103:{c:"j",f:"FR"},120104:{c:"k",f:"FR"},120105:{c:"l",f:"FR"},120106:{c:"m",f:"FR"},120107:{c:"n",f:"FR"},120108:{c:"o",f:"FR"},120109:{c:"p",f:"FR"},120110:{c:"q",f:"FR"},120111:{c:"r",f:"FR"},120112:{c:"s",f:"FR"},120113:{c:"t",f:"FR"},120114:{c:"u",f:"FR"},120115:{c:"v",f:"FR"},120116:{c:"w",f:"FR"},120117:{c:"x",f:"FR"},120118:{c:"y",f:"FR"},120119:{c:"z",f:"FR"},120120:{c:"A",f:"A"},120121:{c:"B",f:"A"},120123:{c:"D",f:"A"},120124:{c:"E",f:"A"},120125:{c:"F",f:"A"},120126:{c:"G",f:"A"},120128:{c:"I",f:"A"},120129:{c:"J",f:"A"},120130:{c:"K",f:"A"},120131:{c:"L",f:"A"},120132:{c:"M",f:"A"},120134:{c:"O",f:"A"},120138:{c:"S",f:"A"},120139:{c:"T",f:"A"},120140:{c:"U",f:"A"},120141:{c:"V",f:"A"},120142:{c:"W",f:"A"},120143:{c:"X",f:"A"},120144:{c:"Y",f:"A"},120172:{c:"A",f:"FRB"},120173:{c:"B",f:"FRB"},120174:{c:"C",f:"FRB"},120175:{c:"D",f:"FRB"},120176:{c:"E",f:"FRB"},120177:{c:"F",f:"FRB"},120178:{c:"G",f:"FRB"},120179:{c:"H",f:"FRB"},120180:{c:"I",f:"FRB"},120181:{c:"J",f:"FRB"},120182:{c:"K",f:"FRB"},120183:{c:"L",f:"FRB"},120184:{c:"M",f:"FRB"},120185:{c:"N",f:"FRB"},120186:{c:"O",f:"FRB"},120187:{c:"P",f:"FRB"},120188:{c:"Q",f:"FRB"},120189:{c:"R",f:"FRB"},120190:{c:"S",f:"FRB"},120191:{c:"T",f:"FRB"},120192:{c:"U",f:"FRB"},120193:{c:"V",f:"FRB"},120194:{c:"W",f:"FRB"},120195:{c:"X",f:"FRB"},120196:{c:"Y",f:"FRB"},120197:{c:"Z",f:"FRB"},120198:{c:"a",f:"FRB"},120199:{c:"b",f:"FRB"},120200:{c:"c",f:"FRB"},120201:{c:"d",f:"FRB"},120202:{c:"e",f:"FRB"},120203:{c:"f",f:"FRB"},120204:{c:"g",f:"FRB"},120205:{c:"h",f:"FRB"},120206:{c:"i",f:"FRB"},120207:{c:"j",f:"FRB"},120208:{c:"k",f:"FRB"},120209:{c:"l",f:"FRB"},120210:{c:"m",f:"FRB"},120211:{c:"n",f:"FRB"},120212:{c:"o",f:"FRB"},120213:{c:"p",f:"FRB"},120214:{c:"q",f:"FRB"},120215:{c:"r",f:"FRB"},120216:{c:"s",f:"FRB"},120217:{c:"t",f:"FRB"},120218:{c:"u",f:"FRB"},120219:{c:"v",f:"FRB"},120220:{c:"w",f:"FRB"},120221:{c:"x",f:"FRB"},120222:{c:"y",f:"FRB"},120223:{c:"z",f:"FRB"},120224:{c:"A",f:"SS"},120225:{c:"B",f:"SS"},120226:{c:"C",f:"SS"},120227:{c:"D",f:"SS"},120228:{c:"E",f:"SS"},120229:{c:"F",f:"SS"},120230:{c:"G",f:"SS"},120231:{c:"H",f:"SS"},120232:{c:"I",f:"SS"},120233:{c:"J",f:"SS"},120234:{c:"K",f:"SS"},120235:{c:"L",f:"SS"},120236:{c:"M",f:"SS"},120237:{c:"N",f:"SS"},120238:{c:"O",f:"SS"},120239:{c:"P",f:"SS"},120240:{c:"Q",f:"SS"},120241:{c:"R",f:"SS"},120242:{c:"S",f:"SS"},120243:{c:"T",f:"SS"},120244:{c:"U",f:"SS"},120245:{c:"V",f:"SS"},120246:{c:"W",f:"SS"},120247:{c:"X",f:"SS"},120248:{c:"Y",f:"SS"},120249:{c:"Z",f:"SS"},120250:{c:"a",f:"SS"},120251:{c:"b",f:"SS"},120252:{c:"c",f:"SS"},120253:{c:"d",f:"SS"},120254:{c:"e",f:"SS"},120255:{c:"f",f:"SS"},120256:{c:"g",f:"SS"},120257:{c:"h",f:"SS"},120258:{c:"i",f:"SS"},120259:{c:"j",f:"SS"},120260:{c:"k",f:"SS"},120261:{c:"l",f:"SS"},120262:{c:"m",f:"SS"},120263:{c:"n",f:"SS"},120264:{c:"o",f:"SS"},120265:{c:"p",f:"SS"},120266:{c:"q",f:"SS"},120267:{c:"r",f:"SS"},120268:{c:"s",f:"SS"},120269:{c:"t",f:"SS"},120270:{c:"u",f:"SS"},120271:{c:"v",f:"SS"},120272:{c:"w",f:"SS"},120273:{c:"x",f:"SS"},120274:{c:"y",f:"SS"},120275:{c:"z",f:"SS"},120276:{c:"A",f:"SSB"},120277:{c:"B",f:"SSB"},120278:{c:"C",f:"SSB"},120279:{c:"D",f:"SSB"},120280:{c:"E",f:"SSB"},120281:{c:"F",f:"SSB"},120282:{c:"G",f:"SSB"},120283:{c:"H",f:"SSB"},120284:{c:"I",f:"SSB"},120285:{c:"J",f:"SSB"},120286:{c:"K",f:"SSB"},120287:{c:"L",f:"SSB"},120288:{c:"M",f:"SSB"},120289:{c:"N",f:"SSB"},120290:{c:"O",f:"SSB"},120291:{c:"P",f:"SSB"},120292:{c:"Q",f:"SSB"},120293:{c:"R",f:"SSB"},120294:{c:"S",f:"SSB"},120295:{c:"T",f:"SSB"},120296:{c:"U",f:"SSB"},120297:{c:"V",f:"SSB"},120298:{c:"W",f:"SSB"},120299:{c:"X",f:"SSB"},120300:{c:"Y",f:"SSB"},120301:{c:"Z",f:"SSB"},120302:{c:"a",f:"SSB"},120303:{c:"b",f:"SSB"},120304:{c:"c",f:"SSB"},120305:{c:"d",f:"SSB"},120306:{c:"e",f:"SSB"},120307:{c:"f",f:"SSB"},120308:{c:"g",f:"SSB"},120309:{c:"h",f:"SSB"},120310:{c:"i",f:"SSB"},120311:{c:"j",f:"SSB"},120312:{c:"k",f:"SSB"},120313:{c:"l",f:"SSB"},120314:{c:"m",f:"SSB"},120315:{c:"n",f:"SSB"},120316:{c:"o",f:"SSB"},120317:{c:"p",f:"SSB"},120318:{c:"q",f:"SSB"},120319:{c:"r",f:"SSB"},120320:{c:"s",f:"SSB"},120321:{c:"t",f:"SSB"},120322:{c:"u",f:"SSB"},120323:{c:"v",f:"SSB"},120324:{c:"w",f:"SSB"},120325:{c:"x",f:"SSB"},120326:{c:"y",f:"SSB"},120327:{c:"z",f:"SSB"},120328:{c:"A",f:"SSI"},120329:{c:"B",f:"SSI"},120330:{c:"C",f:"SSI"},120331:{c:"D",f:"SSI"},120332:{c:"E",f:"SSI"},120333:{c:"F",f:"SSI"},120334:{c:"G",f:"SSI"},120335:{c:"H",f:"SSI"},120336:{c:"I",f:"SSI"},120337:{c:"J",f:"SSI"},120338:{c:"K",f:"SSI"},120339:{c:"L",f:"SSI"},120340:{c:"M",f:"SSI"},120341:{c:"N",f:"SSI"},120342:{c:"O",f:"SSI"},120343:{c:"P",f:"SSI"},120344:{c:"Q",f:"SSI"},120345:{c:"R",f:"SSI"},120346:{c:"S",f:"SSI"},120347:{c:"T",f:"SSI"},120348:{c:"U",f:"SSI"},120349:{c:"V",f:"SSI"},120350:{c:"W",f:"SSI"},120351:{c:"X",f:"SSI"},120352:{c:"Y",f:"SSI"},120353:{c:"Z",f:"SSI"},120354:{c:"a",f:"SSI"},120355:{c:"b",f:"SSI"},120356:{c:"c",f:"SSI"},120357:{c:"d",f:"SSI"},120358:{c:"e",f:"SSI"},120359:{c:"f",f:"SSI"},120360:{c:"g",f:"SSI"},120361:{c:"h",f:"SSI"},120362:{c:"i",f:"SSI"},120363:{c:"j",f:"SSI"},120364:{c:"k",f:"SSI"},120365:{c:"l",f:"SSI"},120366:{c:"m",f:"SSI"},120367:{c:"n",f:"SSI"},120368:{c:"o",f:"SSI"},120369:{c:"p",f:"SSI"},120370:{c:"q",f:"SSI"},120371:{c:"r",f:"SSI"},120372:{c:"s",f:"SSI"},120373:{c:"t",f:"SSI"},120374:{c:"u",f:"SSI"},120375:{c:"v",f:"SSI"},120376:{c:"w",f:"SSI"},120377:{c:"x",f:"SSI"},120378:{c:"y",f:"SSI"},120379:{c:"z",f:"SSI"},120432:{c:"A",f:"T"},120433:{c:"B",f:"T"},120434:{c:"C",f:"T"},120435:{c:"D",f:"T"},120436:{c:"E",f:"T"},120437:{c:"F",f:"T"},120438:{c:"G",f:"T"},120439:{c:"H",f:"T"},120440:{c:"I",f:"T"},120441:{c:"J",f:"T"},120442:{c:"K",f:"T"},120443:{c:"L",f:"T"},120444:{c:"M",f:"T"},120445:{c:"N",f:"T"},120446:{c:"O",f:"T"},120447:{c:"P",f:"T"},120448:{c:"Q",f:"T"},120449:{c:"R",f:"T"},120450:{c:"S",f:"T"},120451:{c:"T",f:"T"},120452:{c:"U",f:"T"},120453:{c:"V",f:"T"},120454:{c:"W",f:"T"},120455:{c:"X",f:"T"},120456:{c:"Y",f:"T"},120457:{c:"Z",f:"T"},120458:{c:"a",f:"T"},120459:{c:"b",f:"T"},120460:{c:"c",f:"T"},120461:{c:"d",f:"T"},120462:{c:"e",f:"T"},120463:{c:"f",f:"T"},120464:{c:"g",f:"T"},120465:{c:"h",f:"T"},120466:{c:"i",f:"T"},120467:{c:"j",f:"T"},120468:{c:"k",f:"T"},120469:{c:"l",f:"T"},120470:{c:"m",f:"T"},120471:{c:"n",f:"T"},120472:{c:"o",f:"T"},120473:{c:"p",f:"T"},120474:{c:"q",f:"T"},120475:{c:"r",f:"T"},120476:{c:"s",f:"T"},120477:{c:"t",f:"T"},120478:{c:"u",f:"T"},120479:{c:"v",f:"T"},120480:{c:"w",f:"T"},120481:{c:"x",f:"T"},120482:{c:"y",f:"T"},120483:{c:"z",f:"T"},120488:{c:"A",f:"B"},120489:{c:"B",f:"B"},120490:{c:"\\393",f:"B"},120491:{c:"\\394",f:"B"},120492:{c:"E",f:"B"},120493:{c:"Z",f:"B"},120494:{c:"H",f:"B"},120495:{c:"\\398",f:"B"},120496:{c:"I",f:"B"},120497:{c:"K",f:"B"},120498:{c:"\\39B",f:"B"},120499:{c:"M",f:"B"},120500:{c:"N",f:"B"},120501:{c:"\\39E",f:"B"},120502:{c:"O",f:"B"},120503:{c:"\\3A0",f:"B"},120504:{c:"P",f:"B"},120506:{c:"\\3A3",f:"B"},120507:{c:"T",f:"B"},120508:{c:"\\3A5",f:"B"},120509:{c:"\\3A6",f:"B"},120510:{c:"X",f:"B"},120511:{c:"\\3A8",f:"B"},120512:{c:"\\3A9",f:"B"},120513:{c:"\\2207",f:"B"},120546:{c:"A",f:"I"},120547:{c:"B",f:"I"},120548:{c:"\\393",f:"I"},120549:{c:"\\394",f:"I"},120550:{c:"E",f:"I"},120551:{c:"Z",f:"I"},120552:{c:"H",f:"I"},120553:{c:"\\398",f:"I"},120554:{c:"I",f:"I"},120555:{c:"K",f:"I"},120556:{c:"\\39B",f:"I"},120557:{c:"M",f:"I"},120558:{c:"N",f:"I"},120559:{c:"\\39E",f:"I"},120560:{c:"O",f:"I"},120561:{c:"\\3A0",f:"I"},120562:{c:"P",f:"I"},120564:{c:"\\3A3",f:"I"},120565:{c:"T",f:"I"},120566:{c:"\\3A5",f:"I"},120567:{c:"\\3A6",f:"I"},120568:{c:"X",f:"I"},120569:{c:"\\3A8",f:"I"},120570:{c:"\\3A9",f:"I"},120572:{c:"\\3B1",f:"I"},120573:{c:"\\3B2",f:"I"},120574:{c:"\\3B3",f:"I"},120575:{c:"\\3B4",f:"I"},120576:{c:"\\3B5",f:"I"},120577:{c:"\\3B6",f:"I"},120578:{c:"\\3B7",f:"I"},120579:{c:"\\3B8",f:"I"},120580:{c:"\\3B9",f:"I"},120581:{c:"\\3BA",f:"I"},120582:{c:"\\3BB",f:"I"},120583:{c:"\\3BC",f:"I"},120584:{c:"\\3BD",f:"I"},120585:{c:"\\3BE",f:"I"},120586:{c:"\\3BF",f:"I"},120587:{c:"\\3C0",f:"I"},120588:{c:"\\3C1",f:"I"},120589:{c:"\\3C2",f:"I"},120590:{c:"\\3C3",f:"I"},120591:{c:"\\3C4",f:"I"},120592:{c:"\\3C5",f:"I"},120593:{c:"\\3C6",f:"I"},120594:{c:"\\3C7",f:"I"},120595:{c:"\\3C8",f:"I"},120596:{c:"\\3C9",f:"I"},120597:{c:"\\2202"},120598:{c:"\\3F5",f:"I"},120599:{c:"\\3D1",f:"I"},120600:{c:"\\E009",f:"A"},120601:{c:"\\3D5",f:"I"},120602:{c:"\\3F1",f:"I"},120603:{c:"\\3D6",f:"I"},120604:{c:"A",f:"BI"},120605:{c:"B",f:"BI"},120606:{c:"\\393",f:"BI"},120607:{c:"\\394",f:"BI"},120608:{c:"E",f:"BI"},120609:{c:"Z",f:"BI"},120610:{c:"H",f:"BI"},120611:{c:"\\398",f:"BI"},120612:{c:"I",f:"BI"},120613:{c:"K",f:"BI"},120614:{c:"\\39B",f:"BI"},120615:{c:"M",f:"BI"},120616:{c:"N",f:"BI"},120617:{c:"\\39E",f:"BI"},120618:{c:"O",f:"BI"},120619:{c:"\\3A0",f:"BI"},120620:{c:"P",f:"BI"},120622:{c:"\\3A3",f:"BI"},120623:{c:"T",f:"BI"},120624:{c:"\\3A5",f:"BI"},120625:{c:"\\3A6",f:"BI"},120626:{c:"X",f:"BI"},120627:{c:"\\3A8",f:"BI"},120628:{c:"\\3A9",f:"BI"},120630:{c:"\\3B1",f:"BI"},120631:{c:"\\3B2",f:"BI"},120632:{c:"\\3B3",f:"BI"},120633:{c:"\\3B4",f:"BI"},120634:{c:"\\3B5",f:"BI"},120635:{c:"\\3B6",f:"BI"},120636:{c:"\\3B7",f:"BI"},120637:{c:"\\3B8",f:"BI"},120638:{c:"\\3B9",f:"BI"},120639:{c:"\\3BA",f:"BI"},120640:{c:"\\3BB",f:"BI"},120641:{c:"\\3BC",f:"BI"},120642:{c:"\\3BD",f:"BI"},120643:{c:"\\3BE",f:"BI"},120644:{c:"\\3BF",f:"BI"},120645:{c:"\\3C0",f:"BI"},120646:{c:"\\3C1",f:"BI"},120647:{c:"\\3C2",f:"BI"},120648:{c:"\\3C3",f:"BI"},120649:{c:"\\3C4",f:"BI"},120650:{c:"\\3C5",f:"BI"},120651:{c:"\\3C6",f:"BI"},120652:{c:"\\3C7",f:"BI"},120653:{c:"\\3C8",f:"BI"},120654:{c:"\\3C9",f:"BI"},120655:{c:"\\2202",f:"B"},120656:{c:"\\3F5",f:"BI"},120657:{c:"\\3D1",f:"BI"},120658:{c:"\\E009",f:"A"},120659:{c:"\\3D5",f:"BI"},120660:{c:"\\3F1",f:"BI"},120661:{c:"\\3D6",f:"BI"},120662:{c:"A",f:"SSB"},120663:{c:"B",f:"SSB"},120664:{c:"\\393",f:"SSB"},120665:{c:"\\394",f:"SSB"},120666:{c:"E",f:"SSB"},120667:{c:"Z",f:"SSB"},120668:{c:"H",f:"SSB"},120669:{c:"\\398",f:"SSB"},120670:{c:"I",f:"SSB"},120671:{c:"K",f:"SSB"},120672:{c:"\\39B",f:"SSB"},120673:{c:"M",f:"SSB"},120674:{c:"N",f:"SSB"},120675:{c:"\\39E",f:"SSB"},120676:{c:"O",f:"SSB"},120677:{c:"\\3A0",f:"SSB"},120678:{c:"P",f:"SSB"},120680:{c:"\\3A3",f:"SSB"},120681:{c:"T",f:"SSB"},120682:{c:"\\3A5",f:"SSB"},120683:{c:"\\3A6",f:"SSB"},120684:{c:"X",f:"SSB"},120685:{c:"\\3A8",f:"SSB"},120686:{c:"\\3A9",f:"SSB"},120782:{c:"0",f:"B"},120783:{c:"1",f:"B"},120784:{c:"2",f:"B"},120785:{c:"3",f:"B"},120786:{c:"4",f:"B"},120787:{c:"5",f:"B"},120788:{c:"6",f:"B"},120789:{c:"7",f:"B"},120790:{c:"8",f:"B"},120791:{c:"9",f:"B"},120802:{c:"0",f:"SS"},120803:{c:"1",f:"SS"},120804:{c:"2",f:"SS"},120805:{c:"3",f:"SS"},120806:{c:"4",f:"SS"},120807:{c:"5",f:"SS"},120808:{c:"6",f:"SS"},120809:{c:"7",f:"SS"},120810:{c:"8",f:"SS"},120811:{c:"9",f:"SS"},120812:{c:"0",f:"SSB"},120813:{c:"1",f:"SSB"},120814:{c:"2",f:"SSB"},120815:{c:"3",f:"SSB"},120816:{c:"4",f:"SSB"},120817:{c:"5",f:"SSB"},120818:{c:"6",f:"SSB"},120819:{c:"7",f:"SSB"},120820:{c:"8",f:"SSB"},120821:{c:"9",f:"SSB"},120822:{c:"0",f:"T"},120823:{c:"1",f:"T"},120824:{c:"2",f:"T"},120825:{c:"3",f:"T"},120826:{c:"4",f:"T"},120827:{c:"5",f:"T"},120828:{c:"6",f:"T"},120829:{c:"7",f:"T"},120830:{c:"8",f:"T"},120831:{c:"9",f:"T"}})},7517:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.sansSerifBoldItalic=void 0;var n=r(8042),o=r(4886);e.sansSerifBoldItalic=(0,n.AddCSS)(o.sansSerifBoldItalic,{305:{f:"SSB"},567:{f:"SSB"}})},4182:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.sansSerifBold=void 0;var n=r(8042),o=r(4471);e.sansSerifBold=(0,n.AddCSS)(o.sansSerifBold,{8213:{c:"\\2014"},8215:{c:"_"},8260:{c:"/"},8710:{c:"\\394"}})},2679:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.sansSerifItalic=void 0;var n=r(8042),o=r(5181);e.sansSerifItalic=(0,n.AddCSS)(o.sansSerifItalic,{913:{c:"A"},914:{c:"B"},917:{c:"E"},918:{c:"Z"},919:{c:"H"},921:{c:"I"},922:{c:"K"},924:{c:"M"},925:{c:"N"},927:{c:"O"},929:{c:"P"},932:{c:"T"},935:{c:"X"},8213:{c:"\\2014"},8215:{c:"_"},8260:{c:"/"},8710:{c:"\\394"}})},5469:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.sansSerif=void 0;var n=r(8042),o=r(3526);e.sansSerif=(0,n.AddCSS)(o.sansSerif,{913:{c:"A"},914:{c:"B"},917:{c:"E"},918:{c:"Z"},919:{c:"H"},921:{c:"I"},922:{c:"K"},924:{c:"M"},925:{c:"N"},927:{c:"O"},929:{c:"P"},932:{c:"T"},935:{c:"X"},8213:{c:"\\2014"},8215:{c:"_"},8260:{c:"/"},8710:{c:"\\394"}})},7563:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.scriptBold=void 0;var n=r(5649);Object.defineProperty(e,"scriptBold",{enumerable:!0,get:function(){return n.scriptBold}})},9409:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.script=void 0;var n=r(7153);Object.defineProperty(e,"script",{enumerable:!0,get:function(){return n.script}})},775:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.smallop=void 0;var n=r(8042),o=r(5745);e.smallop=(0,n.AddCSS)(o.smallop,{8260:{c:"/"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},10072:{c:"\\2223"},10764:{c:"\\222C\\222C"},12296:{c:"\\27E8"},12297:{c:"\\27E9"}})},9551:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texCalligraphicBold=void 0;var n=r(8042),o=r(1411);e.texCalligraphicBold=(0,n.AddCSS)(o.texCalligraphicBold,{305:{f:"B"},567:{f:"B"}})},7907:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texCalligraphic=void 0;var n=r(6384);Object.defineProperty(e,"texCalligraphic",{enumerable:!0,get:function(){return n.texCalligraphic}})},9659:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texMathit=void 0;var n=r(6041);Object.defineProperty(e,"texMathit",{enumerable:!0,get:function(){return n.texMathit}})},98:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texOldstyleBold=void 0;var n=r(8199);Object.defineProperty(e,"texOldstyleBold",{enumerable:!0,get:function(){return n.texOldstyleBold}})},6275:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texOldstyle=void 0;var n=r(9848);Object.defineProperty(e,"texOldstyle",{enumerable:!0,get:function(){return n.texOldstyle}})},6530:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texSize3=void 0;var n=r(8042),o=r(7906);e.texSize3=(0,n.AddCSS)(o.texSize3,{8260:{c:"/"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},12296:{c:"\\27E8"},12297:{c:"\\27E9"}})},4409:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texSize4=void 0;var n=r(8042),o=r(2644);e.texSize4=(0,n.AddCSS)(o.texSize4,{8260:{c:"/"},9001:{c:"\\27E8"},9002:{c:"\\27E9"},12296:{c:"\\27E8"},12297:{c:"\\27E9"},57685:{c:"\\E153\\E152"},57686:{c:"\\E151\\E150"}})},5292:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.texVariant=void 0;var n=r(8042),o=r(4926);e.texVariant=(0,n.AddCSS)(o.texVariant,{1008:{c:"\\E009"},8463:{f:""},8740:{c:"\\E006"},8742:{c:"\\E007"},8808:{c:"\\E00C"},8809:{c:"\\E00D"},8816:{c:"\\E011"},8817:{c:"\\E00E"},8840:{c:"\\E016"},8841:{c:"\\E018"},8842:{c:"\\E01A"},8843:{c:"\\E01B"},10887:{c:"\\E010"},10888:{c:"\\E00F"},10955:{c:"\\E017"},10956:{c:"\\E019"}})},5884:function(t,e,r){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.FontData=e.NOSTRETCH=e.H=e.V=void 0;var a=r(7233);e.V=1,e.H=2,e.NOSTRETCH={dir:0};var l=function(){function t(t){var e,r,l,c;void 0===t&&(t=null),this.variant={},this.delimiters={},this.cssFontMap={},this.remapChars={},this.skewIcFactor=.75;var u=this.constructor;this.options=(0,a.userOptions)((0,a.defaultOptions)({},u.OPTIONS),t),this.params=n({},u.defaultParams),this.sizeVariants=i([],o(u.defaultSizeVariants),!1),this.stretchVariants=i([],o(u.defaultStretchVariants),!1),this.cssFontMap=n({},u.defaultCssFonts);try{for(var p=s(Object.keys(this.cssFontMap)),h=p.next();!h.done;h=p.next()){var f=h.value;"unknown"===this.cssFontMap[f][0]&&(this.cssFontMap[f][0]=this.options.unknownFamily)}}catch(t){e={error:t}}finally{try{h&&!h.done&&(r=p.return)&&r.call(p)}finally{if(e)throw e.error}}this.cssFamilyPrefix=u.defaultCssFamilyPrefix,this.createVariants(u.defaultVariants),this.defineDelimiters(u.defaultDelimiters);try{for(var d=s(Object.keys(u.defaultChars)),m=d.next();!m.done;m=d.next()){var y=m.value;this.defineChars(y,u.defaultChars[y])}}catch(t){l={error:t}}finally{try{m&&!m.done&&(c=d.return)&&c.call(d)}finally{if(l)throw l.error}}this.defineRemap("accent",u.defaultAccentMap),this.defineRemap("mo",u.defaultMoMap),this.defineRemap("mn",u.defaultMnMap)}return t.charOptions=function(t,e){var r=t[e];return 3===r.length&&(r[3]={}),r[3]},Object.defineProperty(t.prototype,"styles",{get:function(){return this._styles},set:function(t){this._styles=t},enumerable:!1,configurable:!0}),t.prototype.createVariant=function(t,e,r){void 0===e&&(e=null),void 0===r&&(r=null);var n={linked:[],chars:e?Object.create(this.variant[e].chars):{}};r&&this.variant[r]&&(Object.assign(n.chars,this.variant[r].chars),this.variant[r].linked.push(n.chars),n.chars=Object.create(n.chars)),this.remapSmpChars(n.chars,t),this.variant[t]=n},t.prototype.remapSmpChars=function(t,e){var r,n,i,a,l=this.constructor;if(l.VariantSmp[e]){var c=l.SmpRemap,u=[null,null,l.SmpRemapGreekU,l.SmpRemapGreekL];try{for(var p=s(l.SmpRanges),h=p.next();!h.done;h=p.next()){var f=o(h.value,3),d=f[0],m=f[1],y=f[2],g=l.VariantSmp[e][d];if(g){for(var b=m;b<=y;b++)if(930!==b){var v=g+b-m;t[b]=this.smpChar(c[v]||v)}if(u[d])try{for(var _=(i=void 0,s(Object.keys(u[d]).map((function(t){return parseInt(t)})))),S=_.next();!S.done;S=_.next()){t[b=S.value]=this.smpChar(g+u[d][b])}}catch(t){i={error:t}}finally{try{S&&!S.done&&(a=_.return)&&a.call(_)}finally{if(i)throw i.error}}}}}catch(t){r={error:t}}finally{try{h&&!h.done&&(n=p.return)&&n.call(p)}finally{if(r)throw r.error}}}"bold"===e&&(t[988]=this.smpChar(120778),t[989]=this.smpChar(120779))},t.prototype.smpChar=function(t){return[,,,{smp:t}]},t.prototype.createVariants=function(t){var e,r;try{for(var n=s(t),o=n.next();!o.done;o=n.next()){var i=o.value;this.createVariant(i[0],i[1],i[2])}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}},t.prototype.defineChars=function(t,e){var r,n,o=this.variant[t];Object.assign(o.chars,e);try{for(var i=s(o.linked),a=i.next();!a.done;a=i.next()){var l=a.value;Object.assign(l,e)}}catch(t){r={error:t}}finally{try{a&&!a.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}},t.prototype.defineDelimiters=function(t){Object.assign(this.delimiters,t)},t.prototype.defineRemap=function(t,e){this.remapChars.hasOwnProperty(t)||(this.remapChars[t]={}),Object.assign(this.remapChars[t],e)},t.prototype.getDelimiter=function(t){return this.delimiters[t]},t.prototype.getSizeVariant=function(t,e){return this.delimiters[t].variants&&(e=this.delimiters[t].variants[e]),this.sizeVariants[e]},t.prototype.getStretchVariant=function(t,e){return this.stretchVariants[this.delimiters[t].stretchv?this.delimiters[t].stretchv[e]:0]},t.prototype.getChar=function(t,e){return this.variant[t].chars[e]},t.prototype.getVariant=function(t){return this.variant[t]},t.prototype.getCssFont=function(t){return this.cssFontMap[t]||["serif",!1,!1]},t.prototype.getFamily=function(t){return this.cssFamilyPrefix?this.cssFamilyPrefix+", "+t:t},t.prototype.getRemappedChar=function(t,e){return(this.remapChars[t]||{})[e]},t.OPTIONS={unknownFamily:"serif"},t.JAX="common",t.NAME="",t.defaultVariants=[["normal"],["bold","normal"],["italic","normal"],["bold-italic","italic","bold"],["double-struck","bold"],["fraktur","normal"],["bold-fraktur","bold","fraktur"],["script","italic"],["bold-script","bold-italic","script"],["sans-serif","normal"],["bold-sans-serif","bold","sans-serif"],["sans-serif-italic","italic","sans-serif"],["sans-serif-bold-italic","bold-italic","bold-sans-serif"],["monospace","normal"]],t.defaultCssFonts={normal:["unknown",!1,!1],bold:["unknown",!1,!0],italic:["unknown",!0,!1],"bold-italic":["unknown",!0,!0],"double-struck":["unknown",!1,!0],fraktur:["unknown",!1,!1],"bold-fraktur":["unknown",!1,!0],script:["cursive",!1,!1],"bold-script":["cursive",!1,!0],"sans-serif":["sans-serif",!1,!1],"bold-sans-serif":["sans-serif",!1,!0],"sans-serif-italic":["sans-serif",!0,!1],"sans-serif-bold-italic":["sans-serif",!0,!0],monospace:["monospace",!1,!1]},t.defaultCssFamilyPrefix="",t.VariantSmp={bold:[119808,119834,120488,120514,120782],italic:[119860,119886,120546,120572],"bold-italic":[119912,119938,120604,120630],script:[119964,119990],"bold-script":[120016,120042],fraktur:[120068,120094],"double-struck":[120120,120146,,,120792],"bold-fraktur":[120172,120198],"sans-serif":[120224,120250,,,120802],"bold-sans-serif":[120276,120302,120662,120688,120812],"sans-serif-italic":[120328,120354],"sans-serif-bold-italic":[120380,120406,120720,120746],monospace:[120432,120458,,,120822]},t.SmpRanges=[[0,65,90],[1,97,122],[2,913,937],[3,945,969],[4,48,57]],t.SmpRemap={119893:8462,119965:8492,119968:8496,119969:8497,119971:8459,119972:8464,119975:8466,119976:8499,119981:8475,119994:8495,119996:8458,120004:8500,120070:8493,120075:8460,120076:8465,120085:8476,120093:8488,120122:8450,120127:8461,120133:8469,120135:8473,120136:8474,120137:8477,120145:8484},t.SmpRemapGreekU={8711:25,1012:17},t.SmpRemapGreekL={977:27,981:29,982:31,1008:28,1009:30,1013:26,8706:25},t.defaultAccentMap={768:"\u02cb",769:"\u02ca",770:"\u02c6",771:"\u02dc",772:"\u02c9",774:"\u02d8",775:"\u02d9",776:"\xa8",778:"\u02da",780:"\u02c7",8594:"\u20d7",8242:"'",8243:"''",8244:"'''",8245:"`",8246:"``",8247:"```",8279:"''''",8400:"\u21bc",8401:"\u21c0",8406:"\u2190",8417:"\u2194",8432:"*",8411:"...",8412:"....",8428:"\u21c1",8429:"\u21bd",8430:"\u2190",8431:"\u2192"},t.defaultMoMap={45:"\u2212"},t.defaultMnMap={45:"\u2212"},t.defaultParams={x_height:.442,quad:1,num1:.676,num2:.394,num3:.444,denom1:.686,denom2:.345,sup1:.413,sup2:.363,sup3:.289,sub1:.15,sub2:.247,sup_drop:.386,sub_drop:.05,delim1:2.39,delim2:1,axis_height:.25,rule_thickness:.06,big_op_spacing1:.111,big_op_spacing2:.167,big_op_spacing3:.2,big_op_spacing4:.6,big_op_spacing5:.1,surd_height:.075,scriptspace:.05,nulldelimiterspace:.12,delimiterfactor:901,delimitershortfall:.3,min_rule_thickness:1.25,separation_factor:1.75,extra_ic:.033},t.defaultDelimiters={},t.defaultChars={},t.defaultSizeVariants=[],t.defaultStretchVariants=[],t}();e.FontData=l},5552:function(t,e){var r=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonArrow=e.CommonDiagonalArrow=e.CommonDiagonalStrike=e.CommonBorder2=e.CommonBorder=e.arrowBBox=e.diagonalArrowDef=e.arrowDef=e.arrowBBoxW=e.arrowBBoxHD=e.arrowHead=e.fullBorder=e.fullPadding=e.fullBBox=e.sideNames=e.sideIndex=e.SOLID=e.PADDING=e.THICKNESS=e.ARROWY=e.ARROWDX=e.ARROWX=void 0,e.ARROWX=4,e.ARROWDX=1,e.ARROWY=2,e.THICKNESS=.067,e.PADDING=.2,e.SOLID=e.THICKNESS+"em solid",e.sideIndex={top:0,right:1,bottom:2,left:3},e.sideNames=Object.keys(e.sideIndex),e.fullBBox=function(t){return new Array(4).fill(t.thickness+t.padding)},e.fullPadding=function(t){return new Array(4).fill(t.padding)},e.fullBorder=function(t){return new Array(4).fill(t.thickness)};e.arrowHead=function(t){return Math.max(t.padding,t.thickness*(t.arrowhead.x+t.arrowhead.dx+1))};e.arrowBBoxHD=function(t,e){if(t.childNodes[0]){var r=t.childNodes[0].getBBox(),n=r.h,o=r.d;e[0]=e[2]=Math.max(0,t.thickness*t.arrowhead.y-(n+o)/2)}return e};e.arrowBBoxW=function(t,e){if(t.childNodes[0]){var r=t.childNodes[0].getBBox().w;e[1]=e[3]=Math.max(0,t.thickness*t.arrowhead.y-r/2)}return e},e.arrowDef={up:[-Math.PI/2,!1,!0,"verticalstrike"],down:[Math.PI/2,!1,!0,"verticakstrike"],right:[0,!1,!1,"horizontalstrike"],left:[Math.PI,!1,!1,"horizontalstrike"],updown:[Math.PI/2,!0,!0,"verticalstrike uparrow downarrow"],leftright:[0,!0,!1,"horizontalstrike leftarrow rightarrow"]},e.diagonalArrowDef={updiagonal:[-1,0,!1,"updiagonalstrike northeastarrow"],northeast:[-1,0,!1,"updiagonalstrike updiagonalarrow"],southeast:[1,0,!1,"downdiagonalstrike"],northwest:[1,Math.PI,!1,"downdiagonalstrike"],southwest:[-1,Math.PI,!1,"updiagonalstrike"],northeastsouthwest:[-1,0,!0,"updiagonalstrike northeastarrow updiagonalarrow southwestarrow"],northwestsoutheast:[1,0,!0,"downdiagonalstrike northwestarrow southeastarrow"]},e.arrowBBox={up:function(t){return(0,e.arrowBBoxW)(t,[(0,e.arrowHead)(t),0,t.padding,0])},down:function(t){return(0,e.arrowBBoxW)(t,[t.padding,0,(0,e.arrowHead)(t),0])},right:function(t){return(0,e.arrowBBoxHD)(t,[0,(0,e.arrowHead)(t),0,t.padding])},left:function(t){return(0,e.arrowBBoxHD)(t,[0,t.padding,0,(0,e.arrowHead)(t)])},updown:function(t){return(0,e.arrowBBoxW)(t,[(0,e.arrowHead)(t),0,(0,e.arrowHead)(t),0])},leftright:function(t){return(0,e.arrowBBoxHD)(t,[0,(0,e.arrowHead)(t),0,(0,e.arrowHead)(t)])}};e.CommonBorder=function(t){return function(r){var n=e.sideIndex[r];return[r,{renderer:t,bbox:function(t){var e=[0,0,0,0];return e[n]=t.thickness+t.padding,e},border:function(t){var e=[0,0,0,0];return e[n]=t.thickness,e}}]}};e.CommonBorder2=function(t){return function(r,n,o){var i=e.sideIndex[n],s=e.sideIndex[o];return[r,{renderer:t,bbox:function(t){var e=t.thickness+t.padding,r=[0,0,0,0];return r[i]=r[s]=e,r},border:function(t){var e=[0,0,0,0];return e[i]=e[s]=t.thickness,e},remove:n+" "+o}]}};e.CommonDiagonalStrike=function(t){return function(r){var n="mjx-"+r.charAt(0)+"strike";return[r+"diagonalstrike",{renderer:t(n),bbox:e.fullBBox}]}};e.CommonDiagonalArrow=function(t){return function(n){var o=r(e.diagonalArrowDef[n],4),i=o[0],s=o[1],a=o[2];return[n+"arrow",{renderer:function(e,n){var o=r(e.arrowAW(),2),l=o[0],c=o[1],u=e.arrow(c,i*(l-s),a);t(e,u)},bbox:function(t){var e=t.arrowData(),n=e.a,o=e.x,i=e.y,s=r([t.arrowhead.x,t.arrowhead.y,t.arrowhead.dx],3),a=s[0],l=s[1],c=s[2],u=r(t.getArgMod(a+c,l),2),p=u[0],h=u[1],f=i+(p>n?t.thickness*h*Math.sin(p-n):0),d=o+(p>Math.PI/2-n?t.thickness*h*Math.sin(p+n-Math.PI/2):0);return[f,d,f,d]},remove:o[3]}]}};e.CommonArrow=function(t){return function(n){var o=r(e.arrowDef[n],4),i=o[0],s=o[1],a=o[2],l=o[3];return[n+"arrow",{renderer:function(e,n){var o=e.getBBox(),l=o.w,c=o.h,u=o.d,p=r(a?[c+u,"X"]:[l,"Y"],2),h=p[0],f=p[1],d=e.getOffset(f),m=e.arrow(h,i,s,f,d);t(e,m)},bbox:e.arrowBBox[n],remove:l}]}}},3055:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonOutputJax=void 0;var l=r(2975),c=r(4474),u=r(7233),p=r(6010),h=r(8054),f=r(4139),d=function(t){function e(e,r,n){void 0===e&&(e=null),void 0===r&&(r=null),void 0===n&&(n=null);var o=this,i=s((0,u.separateOptions)(e,n.OPTIONS),2),a=i[0],l=i[1];return(o=t.call(this,a)||this).factory=o.options.wrapperFactory||new r,o.factory.jax=o,o.cssStyles=o.options.cssStyles||new f.CssStyles,o.font=o.options.font||new n(l),o.unknownCache=new Map,o}return o(e,t),e.prototype.typeset=function(t,e){this.setDocument(e);var r=this.createNode();return this.toDOM(t,r,e),r},e.prototype.createNode=function(){var t=this.constructor.NAME;return this.html("mjx-container",{class:"MathJax",jax:t})},e.prototype.setScale=function(t){var e=this.math.metrics.scale*this.options.scale;1!==e&&this.adaptor.setStyle(t,"fontSize",(0,p.percent)(e))},e.prototype.toDOM=function(t,e,r){void 0===r&&(r=null),this.setDocument(r),this.math=t,this.pxPerEm=t.metrics.ex/this.font.params.x_height,t.root.setTeXclass(null),this.setScale(e),this.nodeMap=new Map,this.container=e,this.processMath(t.root,e),this.nodeMap=null,this.executeFilters(this.postFilters,t,r,e)},e.prototype.getBBox=function(t,e){this.setDocument(e),this.math=t,t.root.setTeXclass(null),this.nodeMap=new Map;var r=this.factory.wrap(t.root).getOuterBBox();return this.nodeMap=null,r},e.prototype.getMetrics=function(t){var e,r;this.setDocument(t);var n=this.adaptor,o=this.getMetricMaps(t);try{for(var i=a(t.math),s=i.next();!s.done;s=i.next()){var l=s.value,u=n.parent(l.start.node);if(l.state()=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},c=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},u=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o600?"bold":"normal"),n.family?r=this.explicitVariant(n.family,n.weight,n.style):(this.node.getProperty("variantForm")&&(r="-tex-variant"),r=(e.BOLDVARIANTS[n.weight]||{})[r]||r,r=(e.ITALICVARIANTS[n.style]||{})[r]||r)}this.variant=r}},e.prototype.explicitVariant=function(t,e,r){var n=this.styles;return n||(n=this.styles=new m.Styles),n.set("fontFamily",t),e&&n.set("fontWeight",e),r&&n.set("fontStyle",r),"-explicitFont"},e.prototype.getScale=function(){var t=1,e=this.parent,r=e?e.bbox.scale:1,n=this.node.attributes,o=Math.min(n.get("scriptlevel"),2),i=n.get("fontsize"),s=this.node.isToken||this.node.isKind("mstyle")?n.get("mathsize"):n.getInherited("mathsize");if(0!==o){t=Math.pow(n.get("scriptsizemultiplier"),o);var a=this.length2em(n.get("scriptminsize"),.8,1);t0;this.bbox.L=n.isSet("lspace")?Math.max(0,this.length2em(n.get("lspace"))):v(o,t.lspace),this.bbox.R=n.isSet("rspace")?Math.max(0,this.length2em(n.get("rspace"))):v(o,t.rspace);var i=r.childIndex(e);if(0!==i){var s=r.childNodes[i-1];if(s.isEmbellished){var a=this.jax.nodeMap.get(s).getBBox();a.R&&(this.bbox.L=Math.max(0,this.bbox.L-a.R))}}}},e.prototype.getTeXSpacing=function(t,e){if(!e){var r=this.node.texSpacing();r&&(this.bbox.L=this.length2em(r))}if(t||e){var n=this.node.coreMO().attributes;n.isSet("lspace")&&(this.bbox.L=Math.max(0,this.length2em(n.get("lspace")))),n.isSet("rspace")&&(this.bbox.R=Math.max(0,this.length2em(n.get("rspace"))))}},e.prototype.isTopEmbellished=function(){return this.node.isEmbellished&&!(this.node.parent&&this.node.parent.isEmbellished)},e.prototype.core=function(){return this.jax.nodeMap.get(this.node.core())},e.prototype.coreMO=function(){return this.jax.nodeMap.get(this.node.coreMO())},e.prototype.getText=function(){var t,e,r="";if(this.node.isToken)try{for(var n=l(this.node.childNodes),o=n.next();!o.done;o=n.next()){var i=o.value;i instanceof h.TextNode&&(r+=i.getText())}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}return r},e.prototype.canStretch=function(t){if(this.stretch=g.NOSTRETCH,this.node.isEmbellished){var e=this.core();e&&e.node!==this.node&&e.canStretch(t)&&(this.stretch=e.stretch)}return 0!==this.stretch.dir},e.prototype.getAlignShift=function(){var t,e=(t=this.node.attributes).getList.apply(t,u([],c(h.indentAttributes),!1)),r=e.indentalign,n=e.indentshift,o=e.indentalignfirst,i=e.indentshiftfirst;return"indentalign"!==o&&(r=o),"auto"===r&&(r=this.jax.options.displayAlign),"indentshift"!==i&&(n=i),"auto"===n&&(n=this.jax.options.displayIndent,"right"!==r||n.match(/^\s*0[a-z]*\s*$/)||(n=("-"+n.trim()).replace(/^--/,""))),[r,this.length2em(n,this.metrics.containerWidth)]},e.prototype.getAlignX=function(t,e,r){return"right"===r?t-(e.w+e.R)*e.rscale:"left"===r?e.L*e.rscale:(t-e.w*e.rscale)/2},e.prototype.getAlignY=function(t,e,r,n,o){return"top"===o?t-r:"bottom"===o?n-e:"center"===o?(t-r-(e-n))/2:0},e.prototype.getWrapWidth=function(t){return this.childNodes[t].getBBox().w},e.prototype.getChildAlign=function(t){return"left"},e.prototype.percent=function(t){return d.percent(t)},e.prototype.em=function(t){return d.em(t)},e.prototype.px=function(t,e){return void 0===e&&(e=-d.BIGDIMEN),d.px(t,e,this.metrics.em)},e.prototype.length2em=function(t,e,r){return void 0===e&&(e=1),void 0===r&&(r=null),null===r&&(r=this.bbox.scale),d.length2em(t,e,r,this.jax.pxPerEm)},e.prototype.unicodeChars=function(t,e){void 0===e&&(e=this.variant);var r=(0,f.unicodeChars)(t),n=this.font.getVariant(e);if(n&&n.chars){var o=n.chars;r=r.map((function(t){return((o[t]||[])[3]||{}).smp||t}))}return r},e.prototype.remapChars=function(t){return t},e.prototype.mmlText=function(t){return this.node.factory.create("text").setText(t)},e.prototype.mmlNode=function(t,e,r){return void 0===e&&(e={}),void 0===r&&(r=[]),this.node.factory.create(t,e,r)},e.prototype.createMo=function(t){var e=this.node.factory,r=e.create("text").setText(t),n=e.create("mo",{stretchy:!0},[r]);n.inheritAttributesFrom(this.node);var o=this.wrap(n);return o.parent=this,o},e.prototype.getVariantChar=function(t,e){var r=this.font.getChar(t,e)||[0,0,0,{unknown:!0}];return 3===r.length&&(r[3]={}),r},e.kind="unknown",e.styles={},e.removeStyles=["fontSize","fontFamily","fontWeight","fontStyle","fontVariant","font"],e.skipAttributes={fontfamily:!0,fontsize:!0,fontweight:!0,fontstyle:!0,color:!0,background:!0,class:!0,href:!0,style:!0,xmlns:!0},e.BOLDVARIANTS={bold:{normal:"bold",italic:"bold-italic",fraktur:"bold-fraktur",script:"bold-script","sans-serif":"bold-sans-serif","sans-serif-italic":"sans-serif-bold-italic"},normal:{bold:"normal","bold-italic":"italic","bold-fraktur":"fraktur","bold-script":"script","bold-sans-serif":"sans-serif","sans-serif-bold-italic":"sans-serif-italic"}},e.ITALICVARIANTS={italic:{normal:"italic",bold:"bold-italic","sans-serif":"sans-serif-italic","bold-sans-serif":"sans-serif-bold-italic"},normal:{italic:"normal","bold-italic":"bold","sans-serif-italic":"sans-serif","sans-serif-bold-italic":"bold-sans-serif"}},e}(p.AbstractWrapper);e.CommonWrapper=_},4420:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonWrapperFactory=void 0;var i=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.jax=null,e}return o(e,t),Object.defineProperty(e.prototype,"Wrappers",{get:function(){return this.node},enumerable:!1,configurable:!0}),e.defaultNodes={},e}(r(3811).AbstractWrapperFactory);e.CommonWrapperFactory=i},9800:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonTeXAtomMixin=void 0;var i=r(9007);e.CommonTeXAtomMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.computeBBox=function(e,r){if(void 0===r&&(r=!1),t.prototype.computeBBox.call(this,e,r),this.childNodes[0]&&this.childNodes[0].bbox.ic&&(e.ic=this.childNodes[0].bbox.ic),this.node.texClass===i.TEXCLASS.VCENTER){var n=e.h,o=(n+e.d)/2+this.font.params.axis_height-n;e.h+=o,e.d-=o}},e}(t)}},1160:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonTextNodeMixin=void 0,e.CommonTextNodeMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.computeBBox=function(t,e){var r,n;void 0===e&&(e=!1);var s=this.parent.variant,a=this.node.getText();if("-explicitFont"===s){var l=this.jax.getFontData(this.parent.styles),c=this.jax.measureText(a,s,l),u=c.w,p=c.h,h=c.d;t.h=p,t.d=h,t.w=u}else{var f=this.remappedText(a,s);t.empty();try{for(var d=o(f),m=d.next();!m.done;m=d.next()){var y=m.value,g=i(this.getVariantChar(s,y),4),b=(p=g[0],h=g[1],u=g[2],g[3]);if(b.unknown){var v=this.jax.measureText(String.fromCodePoint(y),s);u=v.w,p=v.h,h=v.d}t.w+=u,p>t.h&&(t.h=p),h>t.d&&(t.d=h),t.ic=b.ic||0,t.sk=b.sk||0,t.dx=b.dx||0}}catch(t){r={error:t}}finally{try{m&&!m.done&&(n=d.return)&&n.call(d)}finally{if(r)throw r.error}}f.length>1&&(t.sk=0),t.clean()}},e.prototype.remappedText=function(t,e){var r=this.parent.stretch.c;return r?[r]:this.parent.remapChars(this.unicodeChars(t,e))},e.prototype.getStyles=function(){},e.prototype.getVariant=function(){},e.prototype.getScale=function(){},e.prototype.getSpace=function(){},e}(t)}},1956:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},c=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMencloseMixin=void 0;var p=a(r(5552)),h=r(505);e.CommonMencloseMixin=function(t){return function(t){function e(){for(var e=[],r=0;r.001?s:0},e.prototype.getArgMod=function(t,e){return[Math.atan2(e,t),Math.sqrt(t*t+e*e)]},e.prototype.arrow=function(t,e,r,n,o){return void 0===n&&(n=""),void 0===o&&(o=0),null},e.prototype.arrowData=function(){var t=l([this.padding,this.thickness],2),e=t[0],r=t[1]*(this.arrowhead.x+Math.max(1,this.arrowhead.dx)),n=this.childNodes[0].getBBox(),o=n.h,i=n.d,s=n.w,a=o+i,c=Math.sqrt(a*a+s*s),u=Math.max(e,r*s/c),p=Math.max(e,r*a/c),h=l(this.getArgMod(s+2*u,a+2*p),2);return{a:h[0],W:h[1],x:u,y:p}},e.prototype.arrowAW=function(){var t=this.childNodes[0].getBBox(),e=t.h,r=t.d,n=t.w,o=l(this.TRBL,4),i=o[0],s=o[1],a=o[2],c=o[3];return this.getArgMod(c+n+s,i+e+r+a)},e.prototype.createMsqrt=function(t){var e=this.node.factory.create("msqrt");e.inheritAttributesFrom(this.node),e.childNodes[0]=t.node;var r=this.wrap(e);return r.parent=this,r},e.prototype.sqrtTRBL=function(){var t=this.msqrt.getBBox(),e=this.msqrt.childNodes[0].getBBox();return[t.h-e.h,0,t.d-e.d,t.w-e.w]},e}(t)}},7555:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMfencedMixin=void 0,e.CommonMfencedMixin=function(t){return function(t){function e(){for(var e=[],r=0;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMmultiscriptsMixin=e.ScriptNames=e.NextScript=void 0;var l=r(6469);e.NextScript={base:"subList",subList:"supList",supList:"subList",psubList:"psupList",psupList:"psubList"},e.ScriptNames=["sup","sup","psup","psub"],e.CommonMmultiscriptsMixin=function(t){return function(t){function r(){for(var e=[],r=0;re.length&&e.push(l.BBox.empty())},r.prototype.combineBBoxLists=function(t,e,r,n){for(var o=0;ot.h&&(t.h=l),c>t.d&&(t.d=c),h>e.h&&(e.h=h),f>e.d&&(e.d=f)}},r.prototype.getScaledWHD=function(t){var e=t.w,r=t.h,n=t.d,o=t.rscale;return[e*o,r*o,n*o]},r.prototype.getUVQ=function(e,r){var n;if(!this.UVQ){var o=i([0,0,0],3),s=o[0],a=o[1],l=o[2];0===e.h&&0===e.d?s=this.getU():0===r.h&&0===r.d?s=-this.getV():(s=(n=i(t.prototype.getUVQ.call(this,e,r),3))[0],a=n[1],l=n[2]),this.UVQ=[s,a,l]}return this.UVQ},r}(t)}},5023:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMnMixin=void 0,e.CommonMnMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.remapChars=function(t){if(t.length){var e=this.font.getRemappedChar("mn",t[0]);if(e){var r=this.unicodeChars(e,this.variant);1===r.length?t[0]=r[0]:t=r.concat(t.slice(1))}}return t},e}(t)}},7096:function(t,e,r){var n,o,i=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),s=this&&this.__assign||function(){return s=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},l=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMoMixin=e.DirectionVH=void 0;var u=r(6469),p=r(505),h=r(5884);e.DirectionVH=((o={})[1]="v",o[2]="h",o),e.CommonMoMixin=function(t){return function(t){function e(){for(var e=[],r=0;r=0)&&(t.w=0)},e.prototype.protoBBox=function(e){var r=0!==this.stretch.dir;r&&null===this.size&&this.getStretchedVariant([0]),r&&this.size<0||(t.prototype.computeBBox.call(this,e),this.copySkewIC(e))},e.prototype.getAccentOffset=function(){var t=u.BBox.empty();return this.protoBBox(t),-t.w/2},e.prototype.getCenterOffset=function(e){return void 0===e&&(e=null),e||(e=u.BBox.empty(),t.prototype.computeBBox.call(this,e)),(e.h+e.d)/2+this.font.params.axis_height-e.h},e.prototype.getVariant=function(){this.node.attributes.get("largeop")?this.variant=this.node.attributes.get("displaystyle")?"-largeop":"-smallop":this.node.attributes.getExplicit("mathvariant")||!1!==this.node.getProperty("pseudoscript")?t.prototype.getVariant.call(this):this.variant="-tex-variant"},e.prototype.canStretch=function(t){if(0!==this.stretch.dir)return this.stretch.dir===t;if(!this.node.attributes.get("stretchy"))return!1;var e=this.getText();if(1!==Array.from(e).length)return!1;var r=this.font.getDelimiter(e.codePointAt(0));return this.stretch=r&&r.dir===t?r:h.NOSTRETCH,0!==this.stretch.dir},e.prototype.getStretchedVariant=function(t,e){var r,n;if(void 0===e&&(e=!1),0!==this.stretch.dir){var o=this.getWH(t),i=this.getSize("minsize",0),a=this.getSize("maxsize",1/0),l=this.node.getProperty("mathaccent");o=Math.max(i,Math.min(a,o));var u=this.font.params.delimiterfactor/1e3,p=this.font.params.delimitershortfall,h=i||e?o:l?Math.min(o/u,o+p):Math.max(o*u,o-p),f=this.stretch,d=f.c||this.getText().codePointAt(0),m=0;if(f.sizes)try{for(var y=c(f.sizes),g=y.next();!g.done;g=y.next()){if(g.value>=h)return l&&m&&m--,this.variant=this.font.getSizeVariant(d,m),this.size=m,void(f.schar&&f.schar[m]&&(this.stretch=s(s({},this.stretch),{c:f.schar[m]})));m++}}catch(t){r={error:t}}finally{try{g&&!g.done&&(n=y.return)&&n.call(y)}finally{if(r)throw r.error}}f.stretch?(this.size=-1,this.invalidateBBox(),this.getStretchBBox(t,this.checkExtendedHeight(o,f),f)):(this.variant=this.font.getSizeVariant(d,m-1),this.size=m-1)}},e.prototype.getSize=function(t,e){var r=this.node.attributes;return r.isSet(t)&&(e=this.length2em(r.get(t),1,1)),e},e.prototype.getWH=function(t){if(0===t.length)return 0;if(1===t.length)return t[0];var e=a(t,2),r=e[0],n=e[1],o=this.font.params.axis_height;return this.node.attributes.get("symmetric")?2*Math.max(r-o,n+o):r+n},e.prototype.getStretchBBox=function(t,e,r){var n;r.hasOwnProperty("min")&&r.min>e&&(e=r.min);var o=a(r.HDW,3),i=o[0],s=o[1],l=o[2];1===this.stretch.dir?(i=(n=a(this.getBaseline(t,e,r),2))[0],s=n[1]):l=e,this.bbox.h=i,this.bbox.d=s,this.bbox.w=l},e.prototype.getBaseline=function(t,e,r){var n=2===t.length&&t[0]+t[1]===e,o=this.node.attributes.get("symmetric"),i=a(n?t:[e,0],2),s=i[0],l=i[1],c=a([s+l,0],2),u=c[0],p=c[1];if(o){var h=this.font.params.axis_height;n&&(u=2*Math.max(s-h,l+h)),p=u/2-h}else if(n)p=l;else{var f=a(r.HDW||[.75,.25],2),d=f[0],m=f[1];p=m*(u/(d+m))}return[u-p,p]},e.prototype.checkExtendedHeight=function(t,e){if(e.fullExt){var r=a(e.fullExt,2),n=r[0],o=r[1];t=o+Math.ceil(Math.max(0,t-o)/n)*n}return t},e.prototype.remapChars=function(t){var e=this.node.getProperty("primes");if(e)return(0,p.unicodeChars)(e);if(1===t.length){var r=this.node.coreParent().parent,n=this.isAccent&&!r.isKind("mrow")?"accent":"mo",o=this.font.getRemappedChar(n,t[0]);o&&(t=this.unicodeChars(o,this.variant))}return t},e}(t)}},6898:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMpaddedMixin=void 0,e.CommonMpaddedMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getDimens=function(){var t=this.node.attributes.getList("width","height","depth","lspace","voffset"),e=this.childNodes[0].getBBox(),r=e.w,n=e.h,o=e.d,i=r,s=n,a=o,l=0,c=0,u=0;""!==t.width&&(r=this.dimen(t.width,e,"w",0)),""!==t.height&&(n=this.dimen(t.height,e,"h",0)),""!==t.depth&&(o=this.dimen(t.depth,e,"d",0)),""!==t.voffset&&(c=this.dimen(t.voffset,e)),""!==t.lspace&&(l=this.dimen(t.lspace,e));var p=this.node.attributes.get("data-align");return p&&(u=this.getAlignX(r,e,p)),[s,a,i,n-s,o-a,r-i,l,c,u]},e.prototype.dimen=function(t,e,r,n){void 0===r&&(r=""),void 0===n&&(n=null);var o=(t=String(t)).match(/width|height|depth/),i=o?e[o[0].charAt(0)]:r?e[r]:0,s=this.length2em(t,i)||0;return t.match(/^[-+]/)&&r&&(s+=i),null!=n&&(s=Math.max(n,s)),s},e.prototype.computeBBox=function(t,e){void 0===e&&(e=!1);var r=o(this.getDimens(),6),n=r[0],i=r[1],s=r[2],a=r[3],l=r[4],c=r[5];t.w=s+c,t.h=n+a,t.d=i+l,this.setChildPWidths(e,t.w)},e.prototype.getWrapWidth=function(t){return this.getBBox().w},e.prototype.getChildAlign=function(t){return this.node.attributes.get("data-align")||"left"},e}(t)}},6991:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMrootMixin=void 0,e.CommonMrootMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"surd",{get:function(){return 2},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"root",{get:function(){return 1},enumerable:!1,configurable:!0}),e.prototype.combineRootBBox=function(t,e,r){var n=this.childNodes[this.root].getOuterBBox(),o=this.getRootDimens(e,r)[1];t.combine(n,0,o)},e.prototype.getRootDimens=function(t,e){var r=this.childNodes[this.surd],n=this.childNodes[this.root].getOuterBBox(),o=(r.size<0?.5:.6)*t.w,i=n.w,s=n.rscale,a=Math.max(i,o/s),l=Math.max(0,a-i);return[a*s-o,this.rootHeight(n,t,r.size,e),l]},e.prototype.rootHeight=function(t,e,r,n){var o=e.h+e.d;return(r<0?1.9:.55*o)-(o-n)+Math.max(0,t.d*t.rscale)},e}(t)}},8411:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonInferredMrowMixin=e.CommonMrowMixin=void 0;var l=r(6469);e.CommonMrowMixin=function(t){return function(t){function e(){for(var e,r,n=[],o=0;o1){var h=0,f=0,d=u>1&&u===p;try{for(var m=a(this.childNodes),y=m.next();!y.done;y=m.next()){var g=0===(x=y.value).stretch.dir;if(d||g){var b=x.getOuterBBox(g),v=b.h,_=b.d,S=b.rscale;(v*=S)>h&&(h=v),(_*=S)>f&&(f=_)}}}catch(t){r={error:t}}finally{try{y&&!y.done&&(n=m.return)&&n.call(m)}finally{if(r)throw r.error}}try{for(var M=a(s),O=M.next();!O.done;O=M.next()){var x;(x=O.value).coreMO().getStretchedVariant([h,f])}}catch(t){o={error:t}}finally{try{O&&!O.done&&(i=M.return)&&i.call(M)}finally{if(o)throw o.error}}}},e}(t)},e.CommonInferredMrowMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.getScale=function(){this.bbox.scale=this.parent.bbox.scale,this.bbox.rscale=1},e}(t)}},4126:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;othis.surdH?(t.h+t.d-(this.surdH-2*e-r/2))/2:e+r/4]},e.prototype.getRootDimens=function(t,e){return[0,0,0,0]},e}(t)}},905:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMsubsupMixin=e.CommonMsupMixin=e.CommonMsubMixin=void 0,e.CommonMsubMixin=function(t){var e;return e=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"scriptChild",{get:function(){return this.childNodes[this.node.sub]},enumerable:!1,configurable:!0}),e.prototype.getOffset=function(){return[0,-this.getV()]},e}(t),e.useIC=!1,e},e.CommonMsupMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"scriptChild",{get:function(){return this.childNodes[this.node.sup]},enumerable:!1,configurable:!0}),e.prototype.getOffset=function(){return[this.getAdjustedIc()-(this.baseRemoveIc?0:this.baseIc),this.getU()]},e}(t)},e.CommonMsubsupMixin=function(t){var e;return e=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.UVQ=null,e}return n(e,t),Object.defineProperty(e.prototype,"subChild",{get:function(){return this.childNodes[this.node.sub]},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"supChild",{get:function(){return this.childNodes[this.node.sup]},enumerable:!1,configurable:!0}),e.prototype.computeBBox=function(t,e){void 0===e&&(e=!1);var r=this.baseChild.getOuterBBox(),n=o([this.subChild.getOuterBBox(),this.supChild.getOuterBBox()],2),i=n[0],s=n[1];t.empty(),t.append(r);var a=this.getBaseWidth(),l=this.getAdjustedIc(),c=o(this.getUVQ(),2),u=c[0],p=c[1];t.combine(i,a,p),t.combine(s,a+l,u),t.w+=this.font.params.scriptspace,t.clean(),this.setChildPWidths(e)},e.prototype.getUVQ=function(t,e){void 0===t&&(t=this.subChild.getOuterBBox()),void 0===e&&(e=this.supChild.getOuterBBox());var r=this.baseCore.getOuterBBox();if(this.UVQ)return this.UVQ;var n=this.font.params,i=3*n.rule_thickness,s=this.length2em(this.node.attributes.get("subscriptshift"),n.sub2),a=this.baseCharZero(r.d*this.baseScale+n.sub_drop*t.rscale),l=o([this.getU(),Math.max(a,s)],2),c=l[0],u=l[1],p=c-e.d*e.rscale-(t.h*t.rscale-u);if(p0&&(c+=h,u-=h)}return c=Math.max(this.length2em(this.node.attributes.get("superscriptshift"),c),c),u=Math.max(this.length2em(this.node.attributes.get("subscriptshift"),u),u),p=c-e.d*e.rscale-(t.h*t.rscale-u),this.UVQ=[c,-u,p],this.UVQ},e}(t),e.useIC=!1,e}},6237:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMtableMixin=void 0;var l=r(6469),c=r(505),u=r(7875);e.CommonMtableMixin=function(t){return function(t){function e(){for(var e=[],r=0;r1){if(null===e){e=0;var d=h>1&&h===f;try{for(var m=a(this.tableRows),y=m.next();!y.done;y=m.next()){var g;if(g=y.value.getChild(t)){var b=0===(M=g.childNodes[0]).stretch.dir;if(d||b){var v=M.getBBox(b).w;v>e&&(e=v)}}}}catch(t){o={error:t}}finally{try{y&&!y.done&&(i=m.return)&&i.call(m)}finally{if(o)throw o.error}}}try{for(var _=a(c),S=_.next();!S.done;S=_.next()){var M;(M=S.value).coreMO().getStretchedVariant([e])}}catch(t){s={error:t}}finally{try{S&&!S.done&&(l=_.return)&&l.call(_)}finally{if(s)throw s.error}}}},e.prototype.getTableData=function(){if(this.data)return this.data;for(var t=new Array(this.numRows).fill(0),e=new Array(this.numRows).fill(0),r=new Array(this.numCols).fill(0),n=new Array(this.numRows),o=new Array(this.numRows),i=[0],s=this.tableRows,a=0;ao[r]&&(o[r]=c),u>i[r]&&(i[r]=u),f>a&&(a=f),s&&p>s[e]&&(s[e]=p),a},e.prototype.extendHD=function(t,e,r,n){var o=(n-(e[t]+r[t]))/2;o<1e-5||(e[t]+=o,r[t]+=o)},e.prototype.recordPWidthCell=function(t,e){t.childNodes[0]&&t.childNodes[0].getBBox().pwidth&&this.pwidthCells.push([t,e])},e.prototype.computeBBox=function(t,e){void 0===e&&(e=!1);var r,n,o=this.getTableData(),s=o.H,a=o.D;if(this.node.attributes.get("equalrows")){var l=this.getEqualRowHeight();r=(0,u.sum)([].concat(this.rLines,this.rSpace))+l*this.numRows}else r=(0,u.sum)(s.concat(a,this.rLines,this.rSpace));r+=2*(this.fLine+this.fSpace[1]);var p=this.getComputedWidths();n=(0,u.sum)(p.concat(this.cLines,this.cSpace))+2*(this.fLine+this.fSpace[0]);var h=this.node.attributes.get("width");"auto"!==h&&(n=Math.max(this.length2em(h,0)+2*this.fLine,n));var f=i(this.getBBoxHD(r),2),d=f[0],m=f[1];t.h=d,t.d=m,t.w=n;var y=i(this.getBBoxLR(),2),g=y[0],b=y[1];t.L=g,t.R=b,(0,c.isPercent)(h)||this.setColumnPWidths()},e.prototype.setChildPWidths=function(t,e,r){var n=this.node.attributes.get("width");if(!(0,c.isPercent)(n))return!1;this.hasLabels||(this.bbox.pwidth="",this.container.bbox.pwidth="");var o=this.bbox,i=o.w,s=o.L,a=o.R,l=this.node.attributes.get("data-width-includes-label"),p=Math.max(i,this.length2em(n,Math.max(e,s+i+a)))-(l?s+a:0),h=this.node.attributes.get("equalcolumns")?Array(this.numCols).fill(this.percent(1/Math.max(1,this.numCols))):this.getColumnAttributes("columnwidth",0);this.cWidths=this.getColumnWidthsFixed(h,p);var f=this.getComputedWidths();return this.pWidth=(0,u.sum)(f.concat(this.cLines,this.cSpace))+2*(this.fLine+this.fSpace[0]),this.isTop&&(this.bbox.w=this.pWidth),this.setColumnPWidths(),this.pWidth!==i&&this.parent.invalidateBBox(),this.pWidth!==i},e.prototype.setColumnPWidths=function(){var t,e,r=this.cWidths;try{for(var n=a(this.pwidthCells),o=n.next();!o.done;o=n.next()){var s=i(o.value,2),l=s[0],c=s[1];l.setChildPWidths(!1,r[c])&&(l.invalidateBBox(),l.getBBox())}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}},e.prototype.getBBoxHD=function(t){var e=i(this.getAlignmentRow(),2),r=e[0],n=e[1];if(null===n){var o=this.font.params.axis_height,s=t/2;return{top:[0,t],center:[s,s],bottom:[t,0],baseline:[s,s],axis:[s+o,s-o]}[r]||[s,s]}var a=this.getVerticalPosition(n,r);return[a,t-a]},e.prototype.getBBoxLR=function(){if(this.hasLabels){var t=this.node.attributes,e=t.get("side"),r=i(this.getPadAlignShift(e),2),n=r[0],o=r[1],s=this.hasLabels&&!!t.get("data-width-includes-label");return s&&this.frame&&this.fSpace[0]&&(n-=this.fSpace[0]),"center"!==o||s?"left"===e?[n,0]:[0,n]:[n,n]}return[0,0]},e.prototype.getPadAlignShift=function(t){var e=this.getTableData().L+this.length2em(this.node.attributes.get("minlabelspacing")),r=i(null==this.styles?["",""]:[this.styles.get("padding-left"),this.styles.get("padding-right")],2),n=r[0],o=r[1];(n||o)&&(e=Math.max(e,this.length2em(n||"0"),this.length2em(o||"0")));var s=i(this.getAlignShift(),2),a=s[0],l=s[1];return a===t&&(l="left"===t?Math.max(e,l)-e:Math.min(-e,l)+e),[e,a,l]},e.prototype.getAlignShift=function(){return this.isTop?t.prototype.getAlignShift.call(this):[this.container.getChildAlign(this.containerI),0]},e.prototype.getWidth=function(){return this.pWidth||this.getBBox().w},e.prototype.getEqualRowHeight=function(){var t=this.getTableData(),e=t.H,r=t.D,n=Array.from(e.keys()).map((function(t){return e[t]+r[t]}));return Math.max.apply(Math,n)},e.prototype.getComputedWidths=function(){var t=this,e=this.getTableData().W,r=Array.from(e.keys()).map((function(r){return"number"==typeof t.cWidths[r]?t.cWidths[r]:e[r]}));return this.node.attributes.get("equalcolumns")&&(r=Array(r.length).fill((0,u.max)(r))),r},e.prototype.getColumnWidths=function(){var t=this.node.attributes.get("width");if(this.node.attributes.get("equalcolumns"))return this.getEqualColumns(t);var e=this.getColumnAttributes("columnwidth",0);return"auto"===t?this.getColumnWidthsAuto(e):(0,c.isPercent)(t)?this.getColumnWidthsPercent(e):this.getColumnWidthsFixed(e,this.length2em(t))},e.prototype.getEqualColumns=function(t){var e,r=Math.max(1,this.numCols);if("auto"===t){var n=this.getTableData().W;e=(0,u.max)(n)}else if((0,c.isPercent)(t))e=this.percent(1/r);else{var o=(0,u.sum)([].concat(this.cLines,this.cSpace))+2*this.fSpace[0];e=Math.max(0,this.length2em(t)-o)/r}return Array(this.numCols).fill(e)},e.prototype.getColumnWidthsAuto=function(t){var e=this;return t.map((function(t){return"auto"===t||"fit"===t?null:(0,c.isPercent)(t)?t:e.length2em(t)}))},e.prototype.getColumnWidthsPercent=function(t){var e=this,r=t.indexOf("fit")>=0,n=(r?this.getTableData():{W:null}).W;return Array.from(t.keys()).map((function(o){var i=t[o];return"fit"===i?null:"auto"===i?r?n[o]:null:(0,c.isPercent)(i)?i:e.length2em(i)}))},e.prototype.getColumnWidthsFixed=function(t,e){var r=this,n=Array.from(t.keys()),o=n.filter((function(e){return"fit"===t[e]})),i=n.filter((function(e){return"auto"===t[e]})),s=o.length||i.length,a=(s?this.getTableData():{W:null}).W,l=e-(0,u.sum)([].concat(this.cLines,this.cSpace))-2*this.fSpace[0],c=l;n.forEach((function(e){var n=t[e];c-="fit"===n||"auto"===n?a[e]:r.length2em(n,l)}));var p=s&&c>0?c/s:0;return n.map((function(e){var n=t[e];return"fit"===n?a[e]+p:"auto"===n?a[e]+(0===o.length?p:0):r.length2em(n,l)}))},e.prototype.getVerticalPosition=function(t,e){for(var r=this.node.attributes.get("equalrows"),n=this.getTableData(),o=n.H,s=n.D,a=r?this.getEqualRowHeight():0,l=this.getRowHalfSpacing(),c=this.fLine,u=0;uthis.numRows?null:n-1]},e.prototype.getColumnAttributes=function(t,e){void 0===e&&(e=1);var r=this.numCols-e,n=this.getAttributeArray(t);if(0===n.length)return null;for(;n.lengthr&&n.splice(r),n},e.prototype.getRowAttributes=function(t,e){void 0===e&&(e=1);var r=this.numRows-e,n=this.getAttributeArray(t);if(0===n.length)return null;for(;n.lengthr&&n.splice(r),n},e.prototype.getAttributeArray=function(t){var e=this.node.attributes.get(t);return e?(0,c.split)(e):[this.node.attributes.getDefault(t)]},e.prototype.addEm=function(t,e){var r=this;return void 0===e&&(e=1),t?t.map((function(t){return r.em(t/e)})):null},e.prototype.convertLengths=function(t){var e=this;return t?t.map((function(t){return e.length2em(t)})):null},e}(t)}},5164:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMtdMixin=void 0,e.CommonMtdMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"fixesPWidth",{get:function(){return!1},enumerable:!1,configurable:!0}),e.prototype.invalidateBBox=function(){this.bboxComputed=!1},e.prototype.getWrapWidth=function(t){var e=this.parent.parent,r=this.parent,n=this.node.childPosition()-(r.labeled?1:0);return"number"==typeof e.cWidths[n]?e.cWidths[n]:e.getTableData().W[n]},e.prototype.getChildAlign=function(t){return this.node.attributes.get("columnalign")},e}(t)}},6319:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMtextMixin=void 0,e.CommonMtextMixin=function(t){var e;return e=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getVariant=function(){var e=this.jax.options,r=this.jax.math.outputData,n=(!!r.merrorFamily||!!e.merrorFont)&&this.node.Parent.isKind("merror");if(r.mtextFamily||e.mtextFont||n){var o=this.node.attributes.get("mathvariant"),i=this.constructor.INHERITFONTS[o]||this.jax.font.getCssFont(o),s=i[0]||(n?r.merrorFamily||e.merrorFont:r.mtextFamily||e.mtextFont);this.variant=this.explicitVariant(s,i[2]?"bold":"",i[1]?"italic":"")}else t.prototype.getVariant.call(this)},e}(t),e.INHERITFONTS={normal:["",!1,!1],bold:["",!1,!0],italic:["",!0,!1],"bold-italic":["",!0,!0]},e}},5766:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonMlabeledtrMixin=e.CommonMtrMixin=void 0,e.CommonMtrMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"fixesPWidth",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"numCells",{get:function(){return this.childNodes.length},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"labeled",{get:function(){return!1},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"tableCells",{get:function(){return this.childNodes},enumerable:!1,configurable:!0}),e.prototype.getChild=function(t){return this.childNodes[t]},e.prototype.getChildBBoxes=function(){return this.childNodes.map((function(t){return t.getBBox()}))},e.prototype.stretchChildren=function(t){var e,r,n,i,s,a;void 0===t&&(t=null);var l=[],c=this.labeled?this.childNodes.slice(1):this.childNodes;try{for(var u=o(c),p=u.next();!p.done;p=u.next()){(E=p.value.childNodes[0]).canStretch(1)&&l.push(E)}}catch(t){e={error:t}}finally{try{p&&!p.done&&(r=u.return)&&r.call(u)}finally{if(e)throw e.error}}var h=l.length,f=this.childNodes.length;if(h&&f>1){if(null===t){var d=0,m=0,y=h>1&&h===f;try{for(var g=o(c),b=g.next();!b.done;b=g.next()){var v=0===(E=b.value.childNodes[0]).stretch.dir;if(y||v){var _=E.getBBox(v),S=_.h,M=_.d;S>d&&(d=S),M>m&&(m=M)}}}catch(t){n={error:t}}finally{try{b&&!b.done&&(i=g.return)&&i.call(g)}finally{if(n)throw n.error}}t=[d,m]}try{for(var O=o(l),x=O.next();!x.done;x=O.next()){var E;(E=x.value).coreMO().getStretchedVariant(t)}}catch(t){s={error:t}}finally{try{x&&!x.done&&(a=O.return)&&a.call(O)}finally{if(s)throw s.error}}}},e}(t)},e.CommonMlabeledtrMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),Object.defineProperty(e.prototype,"numCells",{get:function(){return Math.max(0,this.childNodes.length-1)},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"labeled",{get:function(){return!0},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"tableCells",{get:function(){return this.childNodes.slice(1)},enumerable:!1,configurable:!0}),e.prototype.getChild=function(t){return this.childNodes[t+1]},e.prototype.getChildBBoxes=function(){return this.childNodes.slice(1).map((function(t){return t.getBBox()}))},e}(t)}},1971:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CommonScriptbaseMixin=void 0;var l=r(9007);e.CommonScriptbaseMixin=function(t){var e;return e=function(t){function e(){for(var e=[],r=0;r1){var h=0,f=u>1&&u===p;try{for(var d=a(this.childNodes),m=d.next();!m.done;m=d.next()){var y=0===(M=m.value).stretch.dir;if(f||y){var g=M.getOuterBBox(y),b=g.w,v=g.rscale;b*v>h&&(h=b*v)}}}catch(t){r={error:t}}finally{try{m&&!m.done&&(n=d.return)&&n.call(d)}finally{if(r)throw r.error}}try{for(var _=a(s),S=_.next();!S.done;S=_.next()){var M;(M=S.value).coreMO().getStretchedVariant([h/M.bbox.rscale])}}catch(t){o={error:t}}finally{try{S&&!S.done&&(i=_.return)&&i.call(_)}finally{if(o)throw o.error}}}},e}(t),e.useIC=!0,e}},5806:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0}),e.CommonSemanticsMixin=void 0,e.CommonSemanticsMixin=function(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.computeBBox=function(t,e){if(void 0===e&&(e=!1),this.childNodes.length){var r=this.childNodes[0].getBBox(),n=r.w,o=r.h,i=r.d;t.w=n,t.h=o,t.d=i}},e}(t)}},5920:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__assign||function(){return o=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.MJContextMenu=void 0;var a=r(5073),l=r(6186),c=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.mathItem=null,e.annotation="",e.annotationTypes={},e}return o(e,t),e.prototype.post=function(e,r){if(this.mathItem){if(void 0!==r){var n=this.mathItem.inputJax.name,o=this.findID("Show","Original");o.content="MathML"===n?"Original MathML":n+" Commands",this.findID("Copy","Original").content=o.content;var i=this.findID("Settings","semantics");"MathML"===n?i.disable():i.enable(),this.getAnnotationMenu(),this.dynamicSubmenus()}t.prototype.post.call(this,e,r)}},e.prototype.unpost=function(){t.prototype.unpost.call(this),this.mathItem=null},e.prototype.findID=function(){for(var t,e,r=[],n=0;n=0)return a}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=o.return)&&r.call(o)}finally{if(e)throw e.error}}return null},e.prototype.createAnnotationMenu=function(t,e,r){var n=this,o=this.findID(t,"Annotation");o.submenu=this.factory.get("subMenu")(this.factory,{items:e.map((function(t){var e=s(t,2),o=e[0],i=e[1];return{type:"command",id:o,content:o,action:function(){n.annotation=i,r()}}})),id:"annotations"},o),e.length?o.enable():o.disable()},e.prototype.dynamicSubmenus=function(){var t,r;try{for(var n=i(e.DynamicSubmenus),o=n.next();!o.done;o=n.next()){var a=s(o.value,2),l=a[0],c=a[1],u=this.find(l);if(u){var p=c(this,u);u.submenu=p,p.items.length?u.enable():u.disable()}}}catch(e){t={error:e}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(t)throw t.error}}},e.DynamicSubmenus=new Map,e}(a.ContextMenu);e.MJContextMenu=c},8310:function(t,e,r){var n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.Menu=void 0;var s=r(5713),a=r(4474),l=r(9515),c=r(7233),u=r(5865),p=r(473),h=r(4414),f=r(4922),d=r(6914),m=r(3463),y=r(7309),g=i(r(5445)),b=l.MathJax,v="undefined"!=typeof window&&window.navigator&&"Mac"===window.navigator.platform.substr(0,3),_=function(){function t(t,e){void 0===e&&(e={});var r=this;this.settings=null,this.defaultSettings=null,this.menu=null,this.MmlVisitor=new p.MmlVisitor,this.jax={CHTML:null,SVG:null},this.rerenderStart=a.STATE.LAST,this.about=new f.Info('MathJax v'+s.mathjax.version,(function(){var t=[];return t.push("Input Jax: "+r.document.inputJax.map((function(t){return t.name})).join(", ")),t.push("Output Jax: "+r.document.outputJax.name),t.push("Document Type: "+r.document.kind),t.join("
")}),'www.mathjax.org'),this.help=new f.Info("MathJax Help",(function(){return["

MathJax is a JavaScript library that allows page"," authors to include mathematics within their web pages."," As a reader, you don't need to do anything to make that happen.

","

Browsers: MathJax works with all modern browsers including"," Edge, Firefox, Chrome, Safari, Opera, and most mobile browsers.

","

Math Menu: MathJax adds a contextual menu to equations."," Right-click or CTRL-click on any mathematics to access the menu.

",'
',"

Show Math As: These options allow you to view the formula's"," source markup (as MathML or in its original format).

","

Copy to Clipboard: These options copy the formula's source markup,"," as MathML or in its original format, to the clipboard"," (in browsers that support that).

","

Math Settings: These give you control over features of MathJax,"," such the size of the mathematics, and the mechanism used"," to display equations.

","

Accessibility: MathJax can work with screen"," readers to make mathematics accessible to the visually impaired."," Turn on the explorer to enable generation of speech strings"," and the ability to investigate expressions interactively.

","

Language: This menu lets you select the language used by MathJax"," for its menus and warning messages. (Not yet implemented in version 3.)

","
","

Math Zoom: If you are having difficulty reading an"," equation, MathJax can enlarge it to help you see it better, or"," you can scall all the math on the page to make it larger."," Turn these features on in the Math Settings menu.

","

Preferences: MathJax uses your browser's localStorage database"," to save the preferences set via this menu locally in your browser. These"," are not used to track you, and are not transferred or used remotely by"," MathJax in any way.

"].join("\n")}),'www.mathjax.org'),this.mathmlCode=new h.SelectableInfo("MathJax MathML Expression",(function(){if(!r.menu.mathItem)return"";var t=r.toMML(r.menu.mathItem);return"
"+r.formatSource(t)+"
"}),""),this.originalText=new h.SelectableInfo("MathJax Original Source",(function(){if(!r.menu.mathItem)return"";var t=r.menu.mathItem.math;return'
'+r.formatSource(t)+"
"}),""),this.annotationText=new h.SelectableInfo("MathJax Annotation Text",(function(){if(!r.menu.mathItem)return"";var t=r.menu.annotation;return'
'+r.formatSource(t)+"
"}),""),this.zoomBox=new f.Info("MathJax Zoomed Expression",(function(){if(!r.menu.mathItem)return"";var t=r.menu.mathItem.typesetRoot.cloneNode(!0);return t.style.margin="0",'
'+t.outerHTML+"
"}),""),this.document=t,this.options=(0,c.userOptions)((0,c.defaultOptions)({},this.constructor.OPTIONS),e),this.initSettings(),this.mergeUserSettings(),this.initMenu(),this.applySettings()}return Object.defineProperty(t.prototype,"isLoading",{get:function(){return t.loading>0},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"loadingPromise",{get:function(){return this.isLoading?(t._loadingPromise||(t._loadingPromise=new Promise((function(e,r){t._loadingOK=e,t._loadingFailed=r}))),t._loadingPromise):Promise.resolve()},enumerable:!1,configurable:!0}),t.prototype.initSettings=function(){this.settings=this.options.settings,this.jax=this.options.jax;var t=this.document.outputJax;this.jax[t.name]=t,this.settings.renderer=t.name,b._.a11y&&b._.a11y.explorer&&Object.assign(this.settings,this.document.options.a11y),this.settings.scale=t.options.scale,this.defaultSettings=Object.assign({},this.settings)},t.prototype.initMenu=function(){var t=this,e=new d.Parser([["contextMenu",u.MJContextMenu.fromJson.bind(u.MJContextMenu)]]);this.menu=e.parse({type:"contextMenu",id:"MathJax_Menu",pool:[this.variable("texHints"),this.variable("semantics"),this.variable("zoom"),this.variable("zscale"),this.variable("renderer",(function(e){return t.setRenderer(e)})),this.variable("alt"),this.variable("cmd"),this.variable("ctrl"),this.variable("shift"),this.variable("scale",(function(e){return t.setScale(e)})),this.variable("explorer",(function(e){return t.setExplorer(e)})),this.a11yVar("highlight"),this.a11yVar("backgroundColor"),this.a11yVar("backgroundOpacity"),this.a11yVar("foregroundColor"),this.a11yVar("foregroundOpacity"),this.a11yVar("speech"),this.a11yVar("subtitles"),this.a11yVar("braille"),this.a11yVar("viewBraille"),this.a11yVar("locale",(function(t){return g.default.setupEngine({locale:t})})),this.a11yVar("speechRules",(function(e){var r=n(e.split("-"),2),o=r[0],i=r[1];t.document.options.sre.domain=o,t.document.options.sre.style=i})),this.a11yVar("magnification"),this.a11yVar("magnify"),this.a11yVar("treeColoring"),this.a11yVar("infoType"),this.a11yVar("infoRole"),this.a11yVar("infoPrefix"),this.variable("autocollapse"),this.variable("collapsible",(function(e){return t.setCollapsible(e)})),this.variable("inTabOrder",(function(e){return t.setTabOrder(e)})),this.variable("assistiveMml",(function(e){return t.setAssistiveMml(e)}))],items:[this.submenu("Show","Show Math As",[this.command("MathMLcode","MathML Code",(function(){return t.mathmlCode.post()})),this.command("Original","Original Form",(function(){return t.originalText.post()})),this.submenu("Annotation","Annotation")]),this.submenu("Copy","Copy to Clipboard",[this.command("MathMLcode","MathML Code",(function(){return t.copyMathML()})),this.command("Original","Original Form",(function(){return t.copyOriginal()})),this.submenu("Annotation","Annotation")]),this.rule(),this.submenu("Settings","Math Settings",[this.submenu("Renderer","Math Renderer",this.radioGroup("renderer",[["CHTML"],["SVG"]])),this.rule(),this.submenu("ZoomTrigger","Zoom Trigger",[this.command("ZoomNow","Zoom Once Now",(function(){return t.zoom(null,"",t.menu.mathItem)})),this.rule(),this.radioGroup("zoom",[["Click"],["DoubleClick","Double-Click"],["NoZoom","No Zoom"]]),this.rule(),this.label("TriggerRequires","Trigger Requires:"),this.checkbox(v?"Option":"Alt",v?"Option":"Alt","alt"),this.checkbox("Command","Command","cmd",{hidden:!v}),this.checkbox("Control","Control","ctrl",{hiddne:v}),this.checkbox("Shift","Shift","shift")]),this.submenu("ZoomFactor","Zoom Factor",this.radioGroup("zscale",[["150%"],["175%"],["200%"],["250%"],["300%"],["400%"]])),this.rule(),this.command("Scale","Scale All Math...",(function(){return t.scaleAllMath()})),this.rule(),this.checkbox("texHints","Add TeX hints to MathML","texHints"),this.checkbox("semantics","Add original as annotation","semantics"),this.rule(),this.command("Reset","Reset to defaults",(function(){return t.resetDefaults()}))]),this.submenu("Accessibility","Accessibility",[this.checkbox("Activate","Activate","explorer"),this.submenu("Speech","Speech",[this.checkbox("Speech","Speech Output","speech"),this.checkbox("Subtitles","Speech Subtitles","subtitles"),this.checkbox("Braille","Braille Output","braille"),this.checkbox("View Braille","Braille Subtitles","viewBraille"),this.rule(),this.submenu("A11yLanguage","Language"),this.rule(),this.submenu("Mathspeak","Mathspeak Rules",this.radioGroup("speechRules",[["mathspeak-default","Verbose"],["mathspeak-brief","Brief"],["mathspeak-sbrief","Superbrief"]])),this.submenu("Clearspeak","Clearspeak Rules",this.radioGroup("speechRules",[["clearspeak-default","Auto"]])),this.submenu("ChromeVox","ChromeVox Rules",this.radioGroup("speechRules",[["chromevox-default","Standard"],["chromevox-alternative","Alternative"]]))]),this.submenu("Highlight","Highlight",[this.submenu("Background","Background",this.radioGroup("backgroundColor",[["Blue"],["Red"],["Green"],["Yellow"],["Cyan"],["Magenta"],["White"],["Black"]])),{type:"slider",variable:"backgroundOpacity",content:" "},this.submenu("Foreground","Foreground",this.radioGroup("foregroundColor",[["Black"],["White"],["Magenta"],["Cyan"],["Yellow"],["Green"],["Red"],["Blue"]])),{type:"slider",variable:"foregroundOpacity",content:" "},this.rule(),this.radioGroup("highlight",[["None"],["Hover"],["Flame"]]),this.rule(),this.checkbox("TreeColoring","Tree Coloring","treeColoring")]),this.submenu("Magnification","Magnification",[this.radioGroup("magnification",[["None"],["Keyboard"],["Mouse"]]),this.rule(),this.radioGroup("magnify",[["200%"],["300%"],["400%"],["500%"]])]),this.submenu("Semantic Info","Semantic Info",[this.checkbox("Type","Type","infoType"),this.checkbox("Role","Role","infoRole"),this.checkbox("Prefix","Prefix","infoPrefix")],!0),this.rule(),this.checkbox("Collapsible","Collapsible Math","collapsible"),this.checkbox("AutoCollapse","Auto Collapse","autocollapse",{disabled:!0}),this.rule(),this.checkbox("InTabOrder","Include in Tab Order","inTabOrder"),this.checkbox("AssistiveMml","Include Hidden MathML","assistiveMml")]),this.submenu("Language","Language"),this.rule(),this.command("About","About MathJax",(function(){return t.about.post()})),this.command("Help","MathJax Help",(function(){return t.help.post()}))]});var r=this.menu;this.about.attachMenu(r),this.help.attachMenu(r),this.originalText.attachMenu(r),this.annotationText.attachMenu(r),this.mathmlCode.attachMenu(r),this.zoomBox.attachMenu(r),this.checkLoadableItems(),this.enableExplorerItems(this.settings.explorer),r.showAnnotation=this.annotationText,r.copyAnnotation=this.copyAnnotation.bind(this),r.annotationTypes=this.options.annotationTypes,y.CssStyles.addInfoStyles(this.document.document),y.CssStyles.addMenuStyles(this.document.document)},t.prototype.checkLoadableItems=function(){var t,e;if(b&&b._&&b.loader&&b.startup)!this.settings.collapsible||b._.a11y&&b._.a11y.complexity||this.loadA11y("complexity"),!this.settings.explorer||b._.a11y&&b._.a11y.explorer||this.loadA11y("explorer"),!this.settings.assistiveMml||b._.a11y&&b._.a11y["assistive-mml"]||this.loadA11y("assistive-mml");else{var r=this.menu;try{for(var n=o(Object.keys(this.jax)),i=n.next();!i.done;i=n.next()){var s=i.value;this.jax[s]||r.findID("Settings","Renderer",s).disable()}}catch(e){t={error:e}}finally{try{i&&!i.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}r.findID("Accessibility","Activate").disable(),r.findID("Accessibility","AutoCollapse").disable(),r.findID("Accessibility","Collapsible").disable()}},t.prototype.enableExplorerItems=function(t){var e,r,n=this.menu.findID("Accessibility","Activate").menu;try{for(var i=o(n.items.slice(1)),s=i.next();!s.done;s=i.next()){var a=s.value;if(a instanceof m.Rule)break;t?a.enable():a.disable()}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}},t.prototype.mergeUserSettings=function(){try{var e=localStorage.getItem(t.MENU_STORAGE);if(!e)return;Object.assign(this.settings,JSON.parse(e)),this.setA11y(this.settings)}catch(t){console.log("MathJax localStorage error: "+t.message)}},t.prototype.saveUserSettings=function(){var e,r,n={};try{for(var i=o(Object.keys(this.settings)),s=i.next();!s.done;s=i.next()){var a=s.value;this.settings[a]!==this.defaultSettings[a]&&(n[a]=this.settings[a])}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}try{Object.keys(n).length?localStorage.setItem(t.MENU_STORAGE,JSON.stringify(n)):localStorage.removeItem(t.MENU_STORAGE)}catch(t){console.log("MathJax localStorage error: "+t.message)}},t.prototype.setA11y=function(t){b._.a11y&&b._.a11y.explorer&&b._.a11y.explorer_ts.setA11yOptions(this.document,t)},t.prototype.getA11y=function(t){if(b._.a11y&&b._.a11y.explorer)return void 0!==this.document.options.a11y[t]?this.document.options.a11y[t]:this.document.options.sre[t]},t.prototype.applySettings=function(){this.setTabOrder(this.settings.inTabOrder),this.document.options.enableAssistiveMml=this.settings.assistiveMml,this.document.outputJax.options.scale=parseFloat(this.settings.scale),this.settings.renderer!==this.defaultSettings.renderer&&this.setRenderer(this.settings.renderer)},t.prototype.setScale=function(t){this.document.outputJax.options.scale=parseFloat(t),this.document.rerender()},t.prototype.setRenderer=function(t){var e=this;if(this.jax[t])this.setOutputJax(t);else{var r=t.toLowerCase();this.loadComponent("output/"+r,(function(){var n=b.startup;r in n.constructors&&(n.useOutput(r,!0),n.output=n.getOutputJax(),e.jax[t]=n.output,e.setOutputJax(t))}))}},t.prototype.setOutputJax=function(t){this.jax[t].setAdaptor(this.document.adaptor),this.document.outputJax=this.jax[t],this.rerender()},t.prototype.setTabOrder=function(t){this.menu.store.inTaborder(t)},t.prototype.setAssistiveMml=function(t){this.document.options.enableAssistiveMml=t,!t||b._.a11y&&b._.a11y["assistive-mml"]?this.rerender():this.loadA11y("assistive-mml")},t.prototype.setExplorer=function(t){this.enableExplorerItems(t),this.document.options.enableExplorer=t,!t||b._.a11y&&b._.a11y.explorer?this.rerender(this.settings.collapsible?a.STATE.RERENDER:a.STATE.COMPILED):this.loadA11y("explorer")},t.prototype.setCollapsible=function(t){this.document.options.enableComplexity=t,!t||b._.a11y&&b._.a11y.complexity?this.rerender(a.STATE.COMPILED):this.loadA11y("complexity")},t.prototype.scaleAllMath=function(){var t=(100*parseFloat(this.settings.scale)).toFixed(1).replace(/.0$/,""),e=prompt("Scale all mathematics (compared to surrounding text) by",t+"%");if(e)if(e.match(/^\s*\d+(\.\d*)?\s*%?\s*$/)){var r=parseFloat(e)/100;r?this.menu.pool.lookup("scale").setValue(String(r)):alert("The scale should not be zero")}else alert("The scale should be a percentage (e.g., 120%)")},t.prototype.resetDefaults=function(){var e,r;t.loading++;var n=this.menu.pool,i=this.defaultSettings;try{for(var s=o(Object.keys(this.settings)),l=s.next();!l.done;l=s.next()){var c=l.value,u=n.lookup(c);if(u){u.setValue(i[c]);var p=u.items[0];p&&p.executeCallbacks_()}else this.settings[c]=i[c]}}catch(t){e={error:t}}finally{try{l&&!l.done&&(r=s.return)&&r.call(s)}finally{if(e)throw e.error}}t.loading--,this.rerender(a.STATE.COMPILED)},t.prototype.checkComponent=function(e){var r=t.loadingPromises.get(e);r&&s.mathjax.retryAfter(r)},t.prototype.loadComponent=function(e,r){if(!t.loadingPromises.has(e)){var n=b.loader;if(n){t.loading++;var o=n.load(e).then((function(){t.loading--,t.loadingPromises.delete(e),r(),0===t.loading&&t._loadingPromise&&(t._loadingPromise=null,t._loadingOK())})).catch((function(e){t._loadingPromise?(t._loadingPromise=null,t._loadingFailed(e)):console.log(e)}));t.loadingPromises.set(e,o)}}},t.prototype.loadA11y=function(e){var r=this,n=!a.STATE.ENRICHED;this.loadComponent("a11y/"+e,(function(){var o=b.startup;s.mathjax.handlers.unregister(o.handler),o.handler=o.getHandler(),s.mathjax.handlers.register(o.handler);var i=r.document;r.document=o.document=o.getDocument(),r.document.menu=r,r.document.outputJax.reset(),r.transferMathList(i),r.document.processed=i.processed,t._loadingPromise||(r.document.outputJax.reset(),r.rerender("complexity"===e||n?a.STATE.COMPILED:a.STATE.TYPESET))}))},t.prototype.transferMathList=function(t){var e,r,n=this.document.options.MathItem;try{for(var i=o(t.math),s=i.next();!s.done;s=i.next()){var a=s.value,l=new n;Object.assign(l,a),this.document.math.push(l)}}catch(t){e={error:t}}finally{try{s&&!s.done&&(r=i.return)&&r.call(i)}finally{if(e)throw e.error}}},t.prototype.formatSource=function(t){return t.trim().replace(/&/g,"&").replace(//g,">")},t.prototype.toMML=function(t){return this.MmlVisitor.visitTree(t.root,t,{texHints:this.settings.texHints,semantics:this.settings.semantics&&"MathML"!==t.inputJax.name})},t.prototype.zoom=function(t,e,r){t&&!this.isZoomEvent(t,e)||(this.menu.mathItem=r,t&&this.menu.post(t),this.zoomBox.post())},t.prototype.isZoomEvent=function(t,e){return this.settings.zoom===e&&(!this.settings.alt||t.altKey)&&(!this.settings.ctrl||t.ctrlKey)&&(!this.settings.cmd||t.metaKey)&&(!this.settings.shift||t.shiftKey)},t.prototype.rerender=function(e){void 0===e&&(e=a.STATE.TYPESET),this.rerenderStart=Math.min(e,this.rerenderStart),t.loading||(this.rerenderStart<=a.STATE.COMPILED&&this.document.reset({inputJax:[]}),this.document.rerender(this.rerenderStart),this.rerenderStart=a.STATE.LAST)},t.prototype.copyMathML=function(){this.copyToClipboard(this.toMML(this.menu.mathItem))},t.prototype.copyOriginal=function(){this.copyToClipboard(this.menu.mathItem.math.trim())},t.prototype.copyAnnotation=function(){this.copyToClipboard(this.menu.annotation.trim())},t.prototype.copyToClipboard=function(t){var e=document.createElement("textarea");e.value=t,e.setAttribute("readonly",""),e.style.cssText="height: 1px; width: 1px; padding: 1px; position: absolute; left: -10px",document.body.appendChild(e),e.select();try{document.execCommand("copy")}catch(t){alert("Can't copy to clipboard: "+t.message)}document.body.removeChild(e)},t.prototype.addMenu=function(t){var e=this,r=t.typesetRoot;r.addEventListener("contextmenu",(function(){return e.menu.mathItem=t}),!0),r.addEventListener("keydown",(function(){return e.menu.mathItem=t}),!0),r.addEventListener("click",(function(r){return e.zoom(r,"Click",t)}),!0),r.addEventListener("dblclick",(function(r){return e.zoom(r,"DoubleClick",t)}),!0),this.menu.store.insert(r)},t.prototype.clear=function(){this.menu.store.clear()},t.prototype.variable=function(t,e){var r=this;return{name:t,getter:function(){return r.settings[t]},setter:function(n){r.settings[t]=n,e&&e(n),r.saveUserSettings()}}},t.prototype.a11yVar=function(t,e){var r=this;return{name:t,getter:function(){return r.getA11y(t)},setter:function(n){r.settings[t]=n;var o={};o[t]=n,r.setA11y(o),e&&e(n),r.saveUserSettings()}}},t.prototype.submenu=function(t,e,r,n){var i,s;void 0===r&&(r=[]),void 0===n&&(n=!1);var a=[];try{for(var l=o(r),c=l.next();!c.done;c=l.next()){var u=c.value;Array.isArray(u)?a=a.concat(u):a.push(u)}}catch(t){i={error:t}}finally{try{c&&!c.done&&(s=l.return)&&s.call(l)}finally{if(i)throw i.error}}return{type:"submenu",id:t,content:e,menu:{items:a},disabled:0===a.length||n}},t.prototype.command=function(t,e,r,n){return void 0===n&&(n={}),Object.assign({type:"command",id:t,content:e,action:r},n)},t.prototype.checkbox=function(t,e,r,n){return void 0===n&&(n={}),Object.assign({type:"checkbox",id:t,content:e,variable:r},n)},t.prototype.radioGroup=function(t,e){var r=this;return e.map((function(e){return r.radio(e[0],e[1]||e[0],t)}))},t.prototype.radio=function(t,e,r,n){return void 0===n&&(n={}),Object.assign({type:"radio",id:t,content:e,variable:r},n)},t.prototype.label=function(t,e){return{type:"label",id:t,content:e}},t.prototype.rule=function(){return{type:"rule"}},t.MENU_STORAGE="MathJax-Menu-Settings",t.OPTIONS={settings:{texHints:!0,semantics:!1,zoom:"NoZoom",zscale:"200%",renderer:"CHTML",alt:!1,cmd:!1,ctrl:!1,shift:!1,scale:1,autocollapse:!1,collapsible:!1,inTabOrder:!0,assistiveMml:!0,explorer:!1},jax:{CHTML:null,SVG:null},annotationTypes:(0,c.expandable)({TeX:["TeX","LaTeX","application/x-tex"],StarMath:["StarMath 5.0"],Maple:["Maple"],ContentMathML:["MathML-Content","application/mathml-content+xml"],OpenMath:["OpenMath"]})},t.loading=0,t.loadingPromises=new Map,t._loadingPromise=null,t._loadingOK=null,t._loadingFailed=null,t}();e.Menu=_},4001:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__assign||function(){return i=Object.assign||function(t){for(var e,r=1,n=arguments.length;r0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MenuHandler=e.MenuMathDocumentMixin=e.MenuMathItemMixin=void 0;var c=r(5713),u=r(4474),p=r(7233),h=r(8310);function f(t){return function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.addMenu=function(t,e){void 0===e&&(e=!1),this.state()>=u.STATE.CONTEXT_MENU||(this.isEscaped||!t.options.enableMenu&&!e||t.menu.addMenu(this),this.state(u.STATE.CONTEXT_MENU))},e.prototype.checkLoading=function(t){t.checkLoading()},e}(t)}function d(t){var e;return e=function(t){function e(){for(var e=[],r=0;r\n"+this.childNodeMml(e,r+" ","\n")+r+""},e.prototype.visitMathNode=function(e,r){if(!this.options.semantics||"TeX"!==this.mathItem.inputJax.name)return t.prototype.visitDefault.call(this,e,r);var n=e.childNodes.length&&e.childNodes[0].childNodes.length>1;return r+"\n"+r+" \n"+(n?r+" \n":"")+this.childNodeMml(e,r+(n?" ":" "),"\n")+(n?r+" \n":"")+r+' '+this.mathItem.math+"\n"+r+" \n"+r+""},e}(i.SerializedMmlVisitor);e.MmlVisitor=a},4414:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.SelectableInfo=void 0;var i=r(4922),s=r(2165),a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.addEvents=function(t){var e=this;t.addEventListener("keypress",(function(t){"a"===t.key&&(t.ctrlKey||t.metaKey)&&(e.selectAll(),e.stop(t))}))},e.prototype.selectAll=function(){document.getSelection().selectAllChildren(this.html.querySelector("pre"))},e.prototype.copyToClipboard=function(){this.selectAll();try{document.execCommand("copy")}catch(t){alert("Can't copy to clipboard: "+t.message)}document.getSelection().removeAllRanges()},e.prototype.generateHtml=function(){var e=this;t.prototype.generateHtml.call(this);var r=this.html.querySelector("span."+s.HtmlClasses.INFOSIGNATURE).appendChild(document.createElement("input"));r.type="button",r.value="Copy to Clipboard",r.addEventListener("click",(function(t){return e.copyToClipboard()}))},e}(i.Info);e.SelectableInfo=a},9923:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.asyncLoad=void 0;var n=r(5713);e.asyncLoad=function(t){return n.mathjax.asyncLoad?new Promise((function(e,r){var o=n.mathjax.asyncLoad(t);o instanceof Promise?o.then((function(t){return e(t)})).catch((function(t){return r(t)})):e(o)})):Promise.reject("Can't load '".concat(t,"': No asyncLoad method specified"))}},6469:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.BBox=void 0;var n=r(6010),o=function(){function t(t){void 0===t&&(t={w:0,h:-n.BIGDIMEN,d:-n.BIGDIMEN}),this.w=t.w||0,this.h="h"in t?t.h:-n.BIGDIMEN,this.d="d"in t?t.d:-n.BIGDIMEN,this.L=this.R=this.ic=this.sk=this.dx=0,this.scale=this.rscale=1,this.pwidth=""}return t.zero=function(){return new t({h:0,d:0,w:0})},t.empty=function(){return new t},t.prototype.empty=function(){return this.w=0,this.h=this.d=-n.BIGDIMEN,this},t.prototype.clean=function(){this.w===-n.BIGDIMEN&&(this.w=0),this.h===-n.BIGDIMEN&&(this.h=0),this.d===-n.BIGDIMEN&&(this.d=0)},t.prototype.rescale=function(t){this.w*=t,this.h*=t,this.d*=t},t.prototype.combine=function(t,e,r){void 0===e&&(e=0),void 0===r&&(r=0);var n=t.rscale,o=e+n*(t.w+t.L+t.R),i=r+n*t.h,s=n*t.d-r;o>this.w&&(this.w=o),i>this.h&&(this.h=i),s>this.d&&(this.d=s)},t.prototype.append=function(t){var e=t.rscale;this.w+=e*(t.w+t.L+t.R),e*t.h>this.h&&(this.h=e*t.h),e*t.d>this.d&&(this.d=e*t.d)},t.prototype.updateFrom=function(t){this.h=t.h,this.d=t.d,this.w=t.w,t.pwidth&&(this.pwidth=t.pwidth)},t.fullWidth="100%",t.StyleAdjust=[["borderTopWidth","h"],["borderRightWidth","w"],["borderBottomWidth","d"],["borderLeftWidth","w",0],["paddingTop","h"],["paddingRight","w"],["paddingBottom","d"],["paddingLeft","w",0]],t}();e.BBox=o},6751:function(t,e){var r,n=this&&this.__extends||(r=function(t,e){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},r(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}r(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}),o=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},i=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},s=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o",gtdot:"\u22d7",harrw:"\u21ad",hbar:"\u210f",hellip:"\u2026",hookleftarrow:"\u21a9",hookrightarrow:"\u21aa",imath:"\u0131",infin:"\u221e",intcal:"\u22ba",iota:"\u03b9",jmath:"\u0237",kappa:"\u03ba",kappav:"\u03f0",lEg:"\u2a8b",lambda:"\u03bb",lap:"\u2a85",larrlp:"\u21ab",larrtl:"\u21a2",lbrace:"{",lbrack:"[",le:"\u2264",leftleftarrows:"\u21c7",leftthreetimes:"\u22cb",lessdot:"\u22d6",lmoust:"\u23b0",lnE:"\u2268",lnap:"\u2a89",lne:"\u2a87",lnsim:"\u22e6",longmapsto:"\u27fc",looparrowright:"\u21ac",lowast:"\u2217",loz:"\u25ca",lt:"<",ltimes:"\u22c9",ltri:"\u25c3",macr:"\xaf",malt:"\u2720",mho:"\u2127",mu:"\u03bc",multimap:"\u22b8",nLeftarrow:"\u21cd",nLeftrightarrow:"\u21ce",nRightarrow:"\u21cf",nVDash:"\u22af",nVdash:"\u22ae",natur:"\u266e",nearr:"\u2197",nharr:"\u21ae",nlarr:"\u219a",not:"\xac",nrarr:"\u219b",nu:"\u03bd",nvDash:"\u22ad",nvdash:"\u22ac",nwarr:"\u2196",omega:"\u03c9",omicron:"\u03bf",or:"\u2228",osol:"\u2298",period:".",phi:"\u03c6",phiv:"\u03d5",pi:"\u03c0",piv:"\u03d6",prap:"\u2ab7",precnapprox:"\u2ab9",precneqq:"\u2ab5",precnsim:"\u22e8",prime:"\u2032",psi:"\u03c8",quot:'"',rarrtl:"\u21a3",rbrace:"}",rbrack:"]",rho:"\u03c1",rhov:"\u03f1",rightrightarrows:"\u21c9",rightthreetimes:"\u22cc",ring:"\u02da",rmoust:"\u23b1",rtimes:"\u22ca",rtri:"\u25b9",scap:"\u2ab8",scnE:"\u2ab6",scnap:"\u2aba",scnsim:"\u22e9",sdot:"\u22c5",searr:"\u2198",sect:"\xa7",sharp:"\u266f",sigma:"\u03c3",sigmav:"\u03c2",simne:"\u2246",smile:"\u2323",spades:"\u2660",sub:"\u2282",subE:"\u2ac5",subnE:"\u2acb",subne:"\u228a",supE:"\u2ac6",supnE:"\u2acc",supne:"\u228b",swarr:"\u2199",tau:"\u03c4",theta:"\u03b8",thetav:"\u03d1",tilde:"\u02dc",times:"\xd7",triangle:"\u25b5",triangleq:"\u225c",upsi:"\u03c5",upuparrows:"\u21c8",veebar:"\u22bb",vellip:"\u22ee",weierp:"\u2118",xi:"\u03be",yen:"\xa5",zeta:"\u03b6",zigrarr:"\u21dd",nbsp:"\xa0",rsquo:"\u2019",lsquo:"\u2018"};var i={};function s(t,r){if("#"===r.charAt(0))return a(r.slice(1));if(e.entities[r])return e.entities[r];if(e.options.loadMissingEntities){var s=r.match(/^[a-zA-Z](fr|scr|opf)$/)?RegExp.$1:r.charAt(0).toLowerCase();i[s]||(i[s]=!0,(0,n.retryAfter)((0,o.asyncLoad)("./util/entities/"+s+".js")))}return t}function a(t){var e="x"===t.charAt(0)?parseInt(t.slice(1),16):parseInt(t);return String.fromCodePoint(e)}e.add=function(t,r){Object.assign(e.entities,t),i[r]=!0},e.remove=function(t){delete e.entities[t]},e.translate=function(t){return t.replace(/&([a-z][a-z0-9]*|#(?:[0-9]+|x[0-9a-f]+));/gi,s)},e.numeric=a},7525:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},n(t,e)},function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},a=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o0&&o[o.length-1])||6!==i[0]&&2!==i[0])){s=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.LinkedList=e.ListItem=e.END=void 0,e.END=Symbol();var s=function(t){void 0===t&&(t=null),this.next=null,this.prev=null,this.data=t};e.ListItem=s;var a=function(){function t(){for(var t=[],r=0;r1;){var c=o.shift(),u=o.shift();c.merge(u,e),o.push(c)}return o.length&&(this.list=o[0].list),this},t.prototype.merge=function(t,r){var o,i,s,a,l;void 0===r&&(r=null),null===r&&(r=this.isBefore.bind(this));for(var c=this.list.next,u=t.list.next;c.data!==e.END&&u.data!==e.END;)r(u.data,c.data)?(o=n([c,u],2),u.prev.next=o[0],c.prev.next=o[1],i=n([c.prev,u.prev],2),u.prev=i[0],c.prev=i[1],s=n([t.list,this.list],2),this.list.prev.next=s[0],t.list.prev.next=s[1],a=n([t.list.prev,this.list.prev],2),this.list.prev=a[0],t.list.prev=a[1],c=(l=n([u.next,c],2))[0],u=l[1]):c=c.next;return u.data!==e.END&&(this.list.prev.next=t.list.next,t.list.next.prev=this.list.prev,t.list.prev.next=this.list,this.list.prev=t.list.prev,t.list.next=t.list.prev=t.list),this},t}();e.LinkedList=a},7233:function(t,e){var r=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;oe.length}}}},t.prototype.add=function(e,r){void 0===r&&(r=t.DEFAULTPRIORITY);var n=this.items.length;do{n--}while(n>=0&&r=0&&this.items[e].item!==t);e>=0&&this.items.splice(e,1)},t.DEFAULTPRIORITY=5,t}();e.PrioritizedList=r},4542:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.retryAfter=e.handleRetriesFor=void 0,e.handleRetriesFor=function(t){return new Promise((function e(r,n){try{r(t())}catch(t){t.retry&&t.retry instanceof Promise?t.retry.then((function(){return e(r,n)})).catch((function(t){return n(t)})):t.restart&&t.restart.isCallback?MathJax.Callback.After((function(){return e(r,n)}),t.restart):n(t)}}))},e.retryAfter=function(t){var e=new Error("MathJax retry");throw e.retry=t,e}},4139:function(t,e){var r=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.CssStyles=void 0;var n=function(){function t(t){void 0===t&&(t=null),this.styles={},this.addStyles(t)}return Object.defineProperty(t.prototype,"cssText",{get:function(){return this.getStyleString()},enumerable:!1,configurable:!0}),t.prototype.addStyles=function(t){var e,n;if(t)try{for(var o=r(Object.keys(t)),i=o.next();!i.done;i=o.next()){var s=i.value;this.styles[s]||(this.styles[s]={}),Object.assign(this.styles[s],t[s])}}catch(t){e={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(e)throw e.error}}},t.prototype.removeStyles=function(){for(var t,e,n=[],o=0;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},n=this&&this.__read||function(t,e){var r="function"==typeof Symbol&&t[Symbol.iterator];if(!r)return t;var n,o,i=r.call(t),s=[];try{for(;(void 0===e||e-- >0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},o=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o1;)e.shift(),r.push(e.shift());return r}function l(t){var e,n,o=a(this.styles[t]);0===o.length&&o.push(""),1===o.length&&o.push(o[0]),2===o.length&&o.push(o[0]),3===o.length&&o.push(o[1]);try{for(var i=r(v.connect[t].children),s=i.next();!s.done;s=i.next()){var l=s.value;this.setStyle(this.childName(t,l),o.shift())}}catch(t){e={error:t}}finally{try{s&&!s.done&&(n=i.return)&&n.call(i)}finally{if(e)throw e.error}}}function c(t){var e,n,o=v.connect[t].children,i=[];try{for(var s=r(o),a=s.next();!a.done;a=s.next()){var l=a.value,c=this.styles[t+"-"+l];if(!c)return void delete this.styles[t];i.push(c)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(n=s.return)&&n.call(s)}finally{if(e)throw e.error}}i[3]===i[1]&&(i.pop(),i[2]===i[0]&&(i.pop(),i[1]===i[0]&&i.pop())),this.styles[t]=i.join(" ")}function u(t){var e,n;try{for(var o=r(v.connect[t].children),i=o.next();!i.done;i=o.next()){var s=i.value;this.setStyle(this.childName(t,s),this.styles[t])}}catch(t){e={error:t}}finally{try{i&&!i.done&&(n=o.return)&&n.call(o)}finally{if(e)throw e.error}}}function p(t){var e,i,s=o([],n(v.connect[t].children),!1),a=this.styles[this.childName(t,s.shift())];try{for(var l=r(s),c=l.next();!c.done;c=l.next()){var u=c.value;if(this.styles[this.childName(t,u)]!==a)return void delete this.styles[t]}}catch(t){e={error:t}}finally{try{c&&!c.done&&(i=l.return)&&i.call(l)}finally{if(e)throw e.error}}this.styles[t]=a}var h=/^(?:[\d.]+(?:[a-z]+)|thin|medium|thick|inherit|initial|unset)$/,f=/^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset|inherit|initial|unset)$/;function d(t){var e,n,o,i,s={width:"",style:"",color:""};try{for(var l=r(a(this.styles[t])),c=l.next();!c.done;c=l.next()){var u=c.value;u.match(h)&&""===s.width?s.width=u:u.match(f)&&""===s.style?s.style=u:s.color=u}}catch(t){e={error:t}}finally{try{c&&!c.done&&(n=l.return)&&n.call(l)}finally{if(e)throw e.error}}try{for(var p=r(v.connect[t].children),d=p.next();!d.done;d=p.next()){var m=d.value;this.setStyle(this.childName(t,m),s[m])}}catch(t){o={error:t}}finally{try{d&&!d.done&&(i=p.return)&&i.call(p)}finally{if(o)throw o.error}}}function m(t){var e,n,o=[];try{for(var i=r(v.connect[t].children),s=i.next();!s.done;s=i.next()){var a=s.value,l=this.styles[this.childName(t,a)];l&&o.push(l)}}catch(t){e={error:t}}finally{try{s&&!s.done&&(n=i.return)&&n.call(i)}finally{if(e)throw e.error}}o.length?this.styles[t]=o.join(" "):delete this.styles[t]}var y={style:/^(?:normal|italic|oblique|inherit|initial|unset)$/,variant:new RegExp("^(?:"+["normal|none","inherit|initial|unset","common-ligatures|no-common-ligatures","discretionary-ligatures|no-discretionary-ligatures","historical-ligatures|no-historical-ligatures","contextual|no-contextual","(?:stylistic|character-variant|swash|ornaments|annotation)\\([^)]*\\)","small-caps|all-small-caps|petite-caps|all-petite-caps|unicase|titling-caps","lining-nums|oldstyle-nums|proportional-nums|tabular-nums","diagonal-fractions|stacked-fractions","ordinal|slashed-zero","jis78|jis83|jis90|jis04|simplified|traditional","full-width|proportional-width","ruby"].join("|")+")$"),weight:/^(?:normal|bold|bolder|lighter|[1-9]00|inherit|initial|unset)$/,stretch:new RegExp("^(?:"+["normal","(?:(?:ultra|extra|semi)-)?condensed","(?:(?:semi|extra|ulta)-)?expanded","inherit|initial|unset"].join("|")+")$"),size:new RegExp("^(?:"+["xx-small|x-small|small|medium|large|x-large|xx-large|larger|smaller","[d.]+%|[d.]+[a-z]+","inherit|initial|unset"].join("|")+")(?:/(?:normal|[d.+](?:%|[a-z]+)?))?$")};function g(t){var e,o,i,s,l=a(this.styles[t]),c={style:"",variant:[],weight:"",stretch:"",size:"",family:"","line-height":""};try{for(var u=r(l),p=u.next();!p.done;p=u.next()){var h=p.value;c.family=h;try{for(var f=(i=void 0,r(Object.keys(y))),d=f.next();!d.done;d=f.next()){var m=d.value;if((Array.isArray(c[m])||""===c[m])&&h.match(y[m]))if("size"===m){var g=n(h.split(/\//),2),b=g[0],_=g[1];c[m]=b,_&&(c["line-height"]=_)}else""===c.size&&(Array.isArray(c[m])?c[m].push(h):c[m]=h)}}catch(t){i={error:t}}finally{try{d&&!d.done&&(s=f.return)&&s.call(f)}finally{if(i)throw i.error}}}}catch(t){e={error:t}}finally{try{p&&!p.done&&(o=u.return)&&o.call(u)}finally{if(e)throw e.error}}!function(t,e){var n,o;try{for(var i=r(v.connect[t].children),s=i.next();!s.done;s=i.next()){var a=s.value,l=this.childName(t,a);if(Array.isArray(e[a])){var c=e[a];c.length&&(this.styles[l]=c.join(" "))}else""!==e[a]&&(this.styles[l]=e[a])}}catch(t){n={error:t}}finally{try{s&&!s.done&&(o=i.return)&&o.call(i)}finally{if(n)throw n.error}}}(t,c),delete this.styles[t]}function b(t){}var v=function(){function t(t){void 0===t&&(t=""),this.parse(t)}return Object.defineProperty(t.prototype,"cssText",{get:function(){var t,e,n=[];try{for(var o=r(Object.keys(this.styles)),i=o.next();!i.done;i=o.next()){var s=i.value,a=this.parentName(s);this.styles[a]||n.push(s+": "+this.styles[s]+";")}}catch(e){t={error:e}}finally{try{i&&!i.done&&(e=o.return)&&e.call(o)}finally{if(t)throw t.error}}return n.join(" ")},enumerable:!1,configurable:!0}),t.prototype.set=function(e,r){for(e=this.normalizeName(e),this.setStyle(e,r),t.connect[e]&&!t.connect[e].combine&&(this.combineChildren(e),delete this.styles[e]);e.match(/-/)&&(e=this.parentName(e),t.connect[e]);)t.connect[e].combine.call(this,e)},t.prototype.get=function(t){return t=this.normalizeName(t),this.styles.hasOwnProperty(t)?this.styles[t]:""},t.prototype.setStyle=function(e,r){this.styles[e]=r,t.connect[e]&&t.connect[e].children&&t.connect[e].split.call(this,e),""===r&&delete this.styles[e]},t.prototype.combineChildren=function(e){var n,o,i=this.parentName(e);try{for(var s=r(t.connect[e].children),a=s.next();!a.done;a=s.next()){var l=a.value,c=this.childName(i,l);t.connect[c].combine.call(this,c)}}catch(t){n={error:t}}finally{try{a&&!a.done&&(o=s.return)&&o.call(s)}finally{if(n)throw n.error}}},t.prototype.parentName=function(t){var e=t.replace(/-[^-]*$/,"");return t===e?"":e},t.prototype.childName=function(e,r){return r.match(/-/)?r:(t.connect[e]&&!t.connect[e].combine&&(r+=e.replace(/.*-/,"-"),e=this.parentName(e)),e+"-"+r)},t.prototype.normalizeName=function(t){return t.replace(/[A-Z]/g,(function(t){return"-"+t.toLowerCase()}))},t.prototype.parse=function(t){void 0===t&&(t="");var e=this.constructor.pattern;this.styles={};for(var r=t.replace(e.comment,"").split(e.style);r.length>1;){var o=n(r.splice(0,3),3),i=o[0],s=o[1],a=o[2];if(i.match(/[^\s\n]/))return;this.set(s,a)}},t.pattern={style:/([-a-z]+)[\s\n]*:[\s\n]*((?:'[^']*'|"[^"]*"|\n|.)*?)[\s\n]*(?:;|$)/g,comment:/\/\*[^]*?\*\//g},t.connect={padding:{children:i,split:l,combine:c},border:{children:i,split:u,combine:p},"border-top":{children:s,split:d,combine:m},"border-right":{children:s,split:d,combine:m},"border-bottom":{children:s,split:d,combine:m},"border-left":{children:s,split:d,combine:m},"border-width":{children:i,split:l,combine:null},"border-style":{children:i,split:l,combine:null},"border-color":{children:i,split:l,combine:null},font:{children:["style","variant","weight","stretch","line-height","size","family"],split:g,combine:b}},t}();e.Styles=v},6010:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.px=e.emRounded=e.em=e.percent=e.length2em=e.MATHSPACE=e.RELUNITS=e.UNITS=e.BIGDIMEN=void 0,e.BIGDIMEN=1e6,e.UNITS={px:1,in:96,cm:96/2.54,mm:96/25.4},e.RELUNITS={em:1,ex:.431,pt:.1,pc:1.2,mu:1/18},e.MATHSPACE={veryverythinmathspace:1/18,verythinmathspace:2/18,thinmathspace:3/18,mediummathspace:4/18,thickmathspace:5/18,verythickmathspace:6/18,veryverythickmathspace:7/18,negativeveryverythinmathspace:-1/18,negativeverythinmathspace:-2/18,negativethinmathspace:-3/18,negativemediummathspace:-4/18,negativethickmathspace:-5/18,negativeverythickmathspace:-6/18,negativeveryverythickmathspace:-7/18,thin:.04,medium:.06,thick:.1,normal:1,big:2,small:1/Math.sqrt(2),infinity:e.BIGDIMEN},e.length2em=function(t,r,n,o){if(void 0===r&&(r=0),void 0===n&&(n=1),void 0===o&&(o=16),"string"!=typeof t&&(t=String(t)),""===t||null==t)return r;if(e.MATHSPACE[t])return e.MATHSPACE[t];var i=t.match(/^\s*([-+]?(?:\.\d+|\d+(?:\.\d*)?))?(pt|em|ex|mu|px|pc|in|mm|cm|%)?/);if(!i)return r;var s=parseFloat(i[1]||"1"),a=i[2];return e.UNITS.hasOwnProperty(a)?s*e.UNITS[a]/o/n:e.RELUNITS.hasOwnProperty(a)?s*e.RELUNITS[a]:"%"===a?s/100*r:s*r},e.percent=function(t){return(100*t).toFixed(1).replace(/\.?0+$/,"")+"%"},e.em=function(t){return Math.abs(t)<.001?"0":t.toFixed(3).replace(/\.?0+$/,"")+"em"},e.emRounded=function(t,e){return void 0===e&&(e=16),t=(Math.round(t*e)+.05)/e,Math.abs(t)<.001?"0em":t.toFixed(3).replace(/\.?0+$/,"")+"em"},e.px=function(t,r,n){return void 0===r&&(r=-e.BIGDIMEN),void 0===n&&(n=16),t*=n,r&&t0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},n=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,o=0,i=e.length;o=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractItem=void 0;var s=r(9329),a=r(2556),l=r(2165),c=function(t){function e(e,r,n,o){var i=t.call(this,e,r)||this;return i._content=n,i.disabled=!1,i.callbacks=[],i._id=o||n,i}return o(e,t),Object.defineProperty(e.prototype,"content",{get:function(){return this._content},set:function(t){this._content=t,this.generateHtml(),this.menu&&this.menu.generateHtml()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"id",{get:function(){return this._id},enumerable:!1,configurable:!0}),e.prototype.press=function(){this.disabled||(this.executeAction(),this.executeCallbacks_())},e.prototype.executeAction=function(){},e.prototype.registerCallback=function(t){-1===this.callbacks.indexOf(t)&&this.callbacks.push(t)},e.prototype.unregisterCallback=function(t){var e=this.callbacks.indexOf(t);-1!==e&&this.callbacks.splice(e,1)},e.prototype.mousedown=function(t){this.press(),this.stop(t)},e.prototype.mouseover=function(t){this.focus(),this.stop(t)},e.prototype.mouseout=function(t){this.deactivate(),this.stop(t)},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this);var e=this.html;e.setAttribute("aria-disabled","false"),e.textContent=this.content},e.prototype.activate=function(){this.disabled||this.html.classList.add(l.HtmlClasses.MENUACTIVE)},e.prototype.deactivate=function(){this.html.classList.remove(l.HtmlClasses.MENUACTIVE)},e.prototype.focus=function(){this.menu.focused=this,t.prototype.focus.call(this),this.activate()},e.prototype.unfocus=function(){this.deactivate(),t.prototype.unfocus.call(this)},e.prototype.escape=function(t){a.MenuUtil.close(this)},e.prototype.up=function(t){this.menu.up(t)},e.prototype.down=function(t){this.menu.down(t)},e.prototype.left=function(t){this.menu.left(t)},e.prototype.right=function(t){this.menu.right(t)},e.prototype.space=function(t){this.press()},e.prototype.disable=function(){this.disabled=!0;var t=this.html;t.classList.add(l.HtmlClasses.MENUDISABLED),t.setAttribute("aria-disabled","true")},e.prototype.enable=function(){this.disabled=!1;var t=this.html;t.classList.remove(l.HtmlClasses.MENUDISABLED),t.removeAttribute("aria-disabled")},e.prototype.executeCallbacks_=function(){var t,e;try{for(var r=i(this.callbacks),n=r.next();!n.done;n=r.next()){var o=n.value;try{o(this)}catch(t){a.MenuUtil.error(t,"Callback for menu entry "+this.id+" failed.")}}}catch(e){t={error:e}}finally{try{n&&!n.done&&(e=r.return)&&e.call(r)}finally{if(t)throw t.error}}},e}(s.AbstractEntry);e.AbstractItem=c},1484:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}),i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractMenu=void 0;var s=r(8372),a=r(1340),l=r(2165),c=r(6186),u=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.className=l.HtmlClasses.CONTEXTMENU,e.role="menu",e._items=[],e._baseMenu=null,e}return o(e,t),Object.defineProperty(e.prototype,"baseMenu",{get:function(){return this._baseMenu},set:function(t){this._baseMenu=t},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"items",{get:function(){return this._items},set:function(t){this._items=t},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"pool",{get:function(){return this.variablePool},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"focused",{get:function(){return this._focused},set:function(t){if(this._focused!==t){this._focused||this.unfocus();var e=this._focused;this._focused=t,e&&e.unfocus()}},enumerable:!1,configurable:!0}),e.prototype.up=function(t){var e=this.items.filter((function(t){return t instanceof a.AbstractItem&&!t.isHidden()}));if(0!==e.length)if(this.focused){var r=e.indexOf(this.focused);-1!==r&&e[r=r?--r:e.length-1].focus()}else e[e.length-1].focus()},e.prototype.down=function(t){var e=this.items.filter((function(t){return t instanceof a.AbstractItem&&!t.isHidden()}));if(0!==e.length)if(this.focused){var r=e.indexOf(this.focused);-1!==r&&e[r=++r===e.length?0:r].focus()}else e[0].focus()},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this),this.generateMenu()},e.prototype.generateMenu=function(){var t,e,r=this.html;r.classList.add(l.HtmlClasses.MENU);try{for(var n=i(this.items),o=n.next();!o.done;o=n.next()){var s=o.value;if(s.isHidden()){var a=s.html;a.parentNode&&a.parentNode.removeChild(a)}else r.appendChild(s.html)}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}},e.prototype.post=function(e,r){this.variablePool.update(),t.prototype.post.call(this,e,r)},e.prototype.unpostSubmenus=function(){var t,e,r=this.items.filter((function(t){return t instanceof c.Submenu}));try{for(var n=i(r),o=n.next();!o.done;o=n.next()){var s=o.value;s.submenu.unpost(),s!==this.focused&&s.unfocus()}}catch(e){t={error:e}}finally{try{o&&!o.done&&(e=n.return)&&e.call(n)}finally{if(t)throw t.error}}},e.prototype.unpost=function(){t.prototype.unpost.call(this),this.unpostSubmenus(),this.focused=null},e.prototype.find=function(t){var e,r;try{for(var n=i(this.items),o=n.next();!o.done;o=n.next()){var s=o.value;if("rule"!==s.type){if(s.id===t)return s;if("submenu"===s.type){var a=s.submenu.find(t);if(a)return a}}}}catch(t){e={error:t}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(e)throw e.error}}return null},e}(s.AbstractPostable);e.AbstractMenu=u},2868:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractNavigatable=void 0;var n=r(3205),o=r(8853),i=function(){function t(){this.bubble=!1}return t.prototype.bubbleKey=function(){this.bubble=!0},t.prototype.keydown=function(t){switch(t.keyCode){case n.KEY.ESCAPE:this.escape(t);break;case n.KEY.RIGHT:this.right(t);break;case n.KEY.LEFT:this.left(t);break;case n.KEY.UP:this.up(t);break;case n.KEY.DOWN:this.down(t);break;case n.KEY.RETURN:case n.KEY.SPACE:this.space(t);break;default:return}this.bubble?this.bubble=!1:this.stop(t)},t.prototype.escape=function(t){},t.prototype.space=function(t){},t.prototype.left=function(t){},t.prototype.right=function(t){},t.prototype.up=function(t){},t.prototype.down=function(t){},t.prototype.stop=function(t){t&&(t.stopPropagation(),t.preventDefault(),t.cancelBubble=!0)},t.prototype.mousedown=function(t){return this.stop(t)},t.prototype.mouseup=function(t){return this.stop(t)},t.prototype.mouseover=function(t){return this.stop(t)},t.prototype.mouseout=function(t){return this.stop(t)},t.prototype.click=function(t){return this.stop(t)},t.prototype.addEvents=function(t){t.addEventListener(o.MOUSE.DOWN,this.mousedown.bind(this)),t.addEventListener(o.MOUSE.UP,this.mouseup.bind(this)),t.addEventListener(o.MOUSE.OVER,this.mouseover.bind(this)),t.addEventListener(o.MOUSE.OUT,this.mouseout.bind(this)),t.addEventListener(o.MOUSE.CLICK,this.click.bind(this)),t.addEventListener("keydown",this.keydown.bind(this)),t.addEventListener("dragstart",this.stop.bind(this)),t.addEventListener(o.MOUSE.SELECTSTART,this.stop.bind(this)),t.addEventListener("contextmenu",this.stop.bind(this)),t.addEventListener(o.MOUSE.DBLCLICK,this.stop.bind(this))},t}();e.AbstractNavigatable=i},8372:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractPostable=void 0;var i=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.posted=!1,e}return o(e,t),e.prototype.isPosted=function(){return this.posted},e.prototype.post=function(t,e){this.posted||(void 0!==t&&void 0!==e&&this.html.setAttribute("style","left: "+t+"px; top: "+e+"px;"),this.display(),this.posted=!0)},e.prototype.unpost=function(){if(this.posted){var t=this.html;t.parentNode&&t.parentNode.removeChild(t),this.posted=!1}},e}(r(9328).MenuElement);e.AbstractPostable=i},6765:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractVariableItem=void 0;var i=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this);var e=this.html;this.span||this.generateSpan(),e.appendChild(this.span),this.update()},e.prototype.register=function(){this.variable.register(this)},e.prototype.unregister=function(){this.variable.unregister(this)},e.prototype.update=function(){this.updateAria(),this.span&&this.updateSpan()},e}(r(1340).AbstractItem);e.AbstractVariableItem=i},5179:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.CloseButton=void 0;var i=r(8372),s=r(2165),a=function(t){function e(e){var r=t.call(this)||this;return r.element=e,r.className=s.HtmlClasses.MENUCLOSE,r.role="button",r}return o(e,t),e.prototype.generateHtml=function(){var t=document.createElement("span");t.classList.add(this.className),t.setAttribute("role",this.role),t.setAttribute("tabindex","0");var e=document.createElement("span");e.textContent="\xd7",t.appendChild(e),this.html=t},e.prototype.display=function(){},e.prototype.unpost=function(){t.prototype.unpost.call(this),this.element.unpost()},e.prototype.keydown=function(e){this.bubbleKey(),t.prototype.keydown.call(this,e)},e.prototype.space=function(t){this.unpost(),this.stop(t)},e.prototype.mousedown=function(t){this.unpost(),this.stop(t)},e}(i.AbstractPostable);e.CloseButton=a},5073:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.ContextMenu=void 0;var i=r(1484),s=r(2165),a=r(1932),l=r(2358),c=function(t){function e(e){var r=t.call(this)||this;return r.factory=e,r.id="",r.moving=!1,r._store=new a.MenuStore(r),r.widgets=[],r.variablePool=new l.VariablePool,r}return o(e,t),e.fromJson=function(t,e){var r=e.pool,n=e.items,o=e.id,i=void 0===o?"":o,s=new this(t);s.id=i;var a=t.get("variable");r.forEach((function(e){return a(t,e,s.pool)}));var l=t.get("items")(t,n,s);return s.items=l,s},e.prototype.generateHtml=function(){this.isPosted()&&this.unpost(),t.prototype.generateHtml.call(this),this._frame=document.createElement("div"),this._frame.classList.add(s.HtmlClasses.MENUFRAME);var e="left: 0px; top: 0px; z-index: 200; width: 100%; height: 100%; border: 0px; padding: 0px; margin: 0px;";this._frame.setAttribute("style","position: absolute; "+e);var r=document.createElement("div");r.setAttribute("style","position: fixed; "+e),this._frame.appendChild(r),r.addEventListener("mousedown",function(t){this.unpost(),this.unpostWidgets(),this.stop(t)}.bind(this))},e.prototype.display=function(){document.body.appendChild(this.frame),this.frame.appendChild(this.html),this.focus()},e.prototype.escape=function(t){this.unpost(),this.unpostWidgets()},e.prototype.unpost=function(){if(t.prototype.unpost.call(this),!(this.widgets.length>0)){this.frame.parentNode.removeChild(this.frame);var e=this.store;this.moving||e.insertTaborder(),e.active.focus()}},e.prototype.left=function(t){this.move_(this.store.previous())},e.prototype.right=function(t){this.move_(this.store.next())},Object.defineProperty(e.prototype,"frame",{get:function(){return this._frame},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"store",{get:function(){return this._store},enumerable:!1,configurable:!0}),e.prototype.post=function(e,r){if(void 0!==r)return this.moving||this.store.removeTaborder(),void t.prototype.post.call(this,e,r);var n,o,i,s=e;if(s instanceof Event?(n=s.target,this.stop(s)):n=s,s instanceof MouseEvent&&(o=s.pageX,i=s.pageY,o||i||!s.clientX||(o=s.clientX+document.body.scrollLeft+document.documentElement.scrollLeft,i=s.clientY+document.body.scrollTop+document.documentElement.scrollTop)),!o&&!i&&n){var a=window.pageXOffset||document.documentElement.scrollLeft,l=window.pageYOffset||document.documentElement.scrollTop,c=n.getBoundingClientRect();o=(c.right+c.left)/2+a,i=(c.bottom+c.top)/2+l}this.store.active=n,this.anchor=this.store.active;var u=this.html;o+u.offsetWidth>document.body.offsetWidth-5&&(o=document.body.offsetWidth-u.offsetWidth-5),this.post(o,i)},e.prototype.registerWidget=function(t){this.widgets.push(t)},e.prototype.unregisterWidget=function(t){var e=this.widgets.indexOf(t);e>-1&&this.widgets.splice(e,1),0===this.widgets.length&&this.unpost()},e.prototype.unpostWidgets=function(){this.widgets.forEach((function(t){return t.unpost()}))},e.prototype.toJson=function(){return{type:""}},e.prototype.move_=function(t){this.anchor&&t!==this.anchor&&(this.moving=!0,this.unpost(),this.post(t),this.moving=!1)},e}(i.AbstractMenu);e.ContextMenu=c},7309:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CssStyles=void 0;var n=r(2165);!function(t){function e(t){return"."+(n.HtmlClasses[t]||t)}var r={};r[e("INFOCLOSE")]="{ top:.2em; right:.2em;}",r[e("INFOCONTENT")]="{ overflow:auto; text-align:left; font-size:80%; padding:.4em .6em; border:1px inset; margin:1em 0px; max-height:20em; max-width:30em; background-color:#EEEEEE; white-space:normal;}",r[e("INFO")+e("MOUSEPOST")]="{outline:none;}",r[e("INFO")]='{ position:fixed; left:50%; width:auto; text-align:center; border:3px outset; padding:1em 2em; background-color:#DDDDDD; color:black; cursor:default; font-family:message-box; font-size:120%; font-style:normal; text-indent:0; text-transform:none; line-height:normal; letter-spacing:normal; word-spacing:normal; word-wrap:normal; white-space:nowrap; float:none; z-index:201; border-radius: 15px; /* Opera 10.5 and IE9 */ -webkit-border-radius:15px; /* Safari and Chrome */ -moz-border-radius:15px; /* Firefox */ -khtml-border-radius:15px; /* Konqueror */ box-shadow:0px 10px 20px #808080; /* Opera 10.5 and IE9 */ -webkit-box-shadow:0px 10px 20px #808080; /* Safari 3 & Chrome */ -moz-box-shadow:0px 10px 20px #808080; /* Forefox 3.5 */ -khtml-box-shadow:0px 10px 20px #808080; /* Konqueror */ filter:progid:DXImageTransform.Microsoft.dropshadow(OffX=2, OffY=2, Color="gray", Positive="true"); /* IE */}';var o={};o[e("MENU")]="{ position:absolute; background-color:white; color:black; width:auto; padding:5px 0px; border:1px solid #CCCCCC; margin:0; cursor:default; font: menu; text-align:left; text-indent:0; text-transform:none; line-height:normal; letter-spacing:normal; word-spacing:normal; word-wrap:normal; white-space:nowrap; float:none; z-index:201; border-radius: 5px; /* Opera 10.5 and IE9 */ -webkit-border-radius: 5px; /* Safari and Chrome */ -moz-border-radius: 5px; /* Firefox */ -khtml-border-radius: 5px; /* Konqueror */ box-shadow:0px 10px 20px #808080; /* Opera 10.5 and IE9 */ -webkit-box-shadow:0px 10px 20px #808080; /* Safari 3 & Chrome */ -moz-box-shadow:0px 10px 20px #808080; /* Forefox 3.5 */ -khtml-box-shadow:0px 10px 20px #808080; /* Konqueror */}",o[e("MENUITEM")]="{ padding: 1px 2em; background:transparent;}",o[e("MENUARROW")]="{ position:absolute; right:.5em; padding-top:.25em; color:#666666; font-family: null; font-size: .75em}",o[e("MENUACTIVE")+" "+e("MENUARROW")]="{color:white}",o[e("MENUARROW")+e("RTL")]="{left:.5em; right:auto}",o[e("MENUCHECK")]="{ position:absolute; left:.7em; font-family: null}",o[e("MENUCHECK")+e("RTL")]="{ right:.7em; left:auto }",o[e("MENURADIOCHECK")]="{ position:absolute; left: .7em;}",o[e("MENURADIOCHECK")+e("RTL")]="{ right: .7em; left:auto}",o[e("MENUINPUTBOX")]="{ padding-left: 1em; right:.5em; color:#666666; font-family: null;}",o[e("MENUINPUTBOX")+e("RTL")]="{ left: .1em;}",o[e("MENUCOMBOBOX")]="{ left:.1em; padding-bottom:.5em;}",o[e("MENUSLIDER")]="{ left: .1em;}",o[e("SLIDERVALUE")]="{ position:absolute; right:.1em; padding-top:.25em; color:#333333; font-size: .75em}",o[e("SLIDERBAR")]="{ outline: none; background: #d3d3d3}",o[e("MENULABEL")]="{ padding: 1px 2em 3px 1.33em; font-style:italic}",o[e("MENURULE")]="{ border-top: 1px solid #DDDDDD; margin: 4px 3px;}",o[e("MENUDISABLED")]="{ color:GrayText}",o[e("MENUACTIVE")]="{ background-color: #606872; color: white;}",o[e("MENUDISABLED")+":focus"]="{ background-color: #E8E8E8}",o[e("MENULABEL")+":focus"]="{ background-color: #E8E8E8}",o[e("CONTEXTMENU")+":focus"]="{ outline:none}",o[e("CONTEXTMENU")+" "+e("MENUITEM")+":focus"]="{ outline:none}",o[e("SELECTIONMENU")]="{ position:relative; float:left; border-bottom: none; -webkit-box-shadow:none; -webkit-border-radius:0px; }",o[e("SELECTIONITEM")]="{ padding-right: 1em;}",o[e("SELECTION")]="{ right: 40%; width:50%; }",o[e("SELECTIONBOX")]="{ padding: 0em; max-height:20em; max-width: none; background-color:#FFFFFF;}",o[e("SELECTIONDIVIDER")]="{ clear: both; border-top: 2px solid #000000;}",o[e("MENU")+" "+e("MENUCLOSE")]="{ top:-10px; left:-10px}";var i={};i[e("MENUCLOSE")]='{ position:absolute; cursor:pointer; display:inline-block; border:2px solid #AAA; border-radius:18px; -webkit-border-radius: 18px; /* Safari and Chrome */ -moz-border-radius: 18px; /* Firefox */ -khtml-border-radius: 18px; /* Konqueror */ font-family: "Courier New", Courier; font-size:24px; color:#F0F0F0}',i[e("MENUCLOSE")+" span"]="{ display:block; background-color:#AAA; border:1.5px solid; border-radius:18px; -webkit-border-radius: 18px; /* Safari and Chrome */ -moz-border-radius: 18px; /* Firefox */ -khtml-border-radius: 18px; /* Konqueror */ line-height:0; padding:8px 0 6px /* may need to be browser-specific */}",i[e("MENUCLOSE")+":hover"]="{ color:white!important; border:2px solid #CCC!important}",i[e("MENUCLOSE")+":hover span"]="{ background-color:#CCC!important}",i[e("MENUCLOSE")+":hover:focus"]="{ outline:none}";var s=!1,a=!1,l=!1;function c(t){l||(u(i,t),l=!0)}function u(t,e){var r=e||document,n=r.createElement("style");n.type="text/css";var o="";for(var i in t)o+=i,o+=" ",o+=t[i],o+="\n";n.innerHTML=o,r.head.appendChild(n)}t.addMenuStyles=function(t){a||(u(o,t),a=!0,c(t))},t.addInfoStyles=function(t){s||(u(r,t),s=!0,c(t))}}(e.CssStyles||(e.CssStyles={}))},2165:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.HtmlAttrs=e.HtmlClasses=void 0;function r(t){return"CtxtMenu_"+t}function n(t){return r(t)}function o(t){return r(t)}e.HtmlClasses={ATTACHED:n("Attached"),CONTEXTMENU:n("ContextMenu"),MENU:n("Menu"),MENUARROW:n("MenuArrow"),MENUACTIVE:n("MenuActive"),MENUCHECK:n("MenuCheck"),MENUCLOSE:n("MenuClose"),MENUCOMBOBOX:n("MenuComboBox"),MENUDISABLED:n("MenuDisabled"),MENUFRAME:n("MenuFrame"),MENUITEM:n("MenuItem"),MENULABEL:n("MenuLabel"),MENURADIOCHECK:n("MenuRadioCheck"),MENUINPUTBOX:n("MenuInputBox"),MENURULE:n("MenuRule"),MENUSLIDER:n("MenuSlider"),MOUSEPOST:n("MousePost"),RTL:n("RTL"),INFO:n("Info"),INFOCLOSE:n("InfoClose"),INFOCONTENT:n("InfoContent"),INFOSIGNATURE:n("InfoSignature"),INFOTITLE:n("InfoTitle"),SLIDERVALUE:n("SliderValue"),SLIDERBAR:n("SliderBar"),SELECTION:n("Selection"),SELECTIONBOX:n("SelectionBox"),SELECTIONMENU:n("SelectionMenu"),SELECTIONDIVIDER:n("SelectionDivider"),SELECTIONITEM:n("SelectionItem")},e.HtmlAttrs={COUNTER:o("Counter"),KEYDOWNFUNC:o("keydownFunc"),CONTEXTMENUFUNC:o("contextmenuFunc"),OLDTAB:o("Oldtabindex"),TOUCHFUNC:o("TouchFunc")}},4922:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Info=void 0;var i=r(5179),s=r(2165),a=function(t){function e(e,r,n){var o=t.call(this)||this;return o.title=e,o.signature=n,o.className=s.HtmlClasses.INFO,o.role="dialog",o.contentDiv=o.generateContent(),o.close=o.generateClose(),o.content=r||function(){return""},o}return o(e,t),e.prototype.attachMenu=function(t){this.menu=t},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this);var e=this.html;e.appendChild(this.generateTitle()),e.appendChild(this.contentDiv),e.appendChild(this.generateSignature()),e.appendChild(this.close.html),e.setAttribute("tabindex","0")},e.prototype.post=function(){t.prototype.post.call(this);var e=document.documentElement,r=this.html,n=window.innerHeight||e.clientHeight||e.scrollHeight||0,o=Math.floor(-r.offsetWidth/2),i=Math.floor((n-r.offsetHeight)/3);r.setAttribute("style","margin-left: "+o+"px; top: "+i+"px;"),window.event instanceof MouseEvent&&r.classList.add(s.HtmlClasses.MOUSEPOST),r.focus()},e.prototype.display=function(){this.menu.registerWidget(this),this.contentDiv.innerHTML=this.content();var t=this.menu.html;t.parentNode&&t.parentNode.removeChild(t),this.menu.frame.appendChild(this.html)},e.prototype.click=function(t){},e.prototype.keydown=function(e){this.bubbleKey(),t.prototype.keydown.call(this,e)},e.prototype.escape=function(t){this.unpost()},e.prototype.unpost=function(){t.prototype.unpost.call(this),this.html.classList.remove(s.HtmlClasses.MOUSEPOST),this.menu.unregisterWidget(this)},e.prototype.generateClose=function(){var t=new i.CloseButton(this),e=t.html;return e.classList.add(s.HtmlClasses.INFOCLOSE),e.setAttribute("aria-label","Close Dialog Box"),t},e.prototype.generateTitle=function(){var t=document.createElement("span");return t.innerHTML=this.title,t.classList.add(s.HtmlClasses.INFOTITLE),t},e.prototype.generateContent=function(){var t=document.createElement("div");return t.classList.add(s.HtmlClasses.INFOCONTENT),t.setAttribute("tabindex","0"),t},e.prototype.generateSignature=function(){var t=document.createElement("span");return t.innerHTML=this.signature,t.classList.add(s.HtmlClasses.INFOSIGNATURE),t},e.prototype.toJson=function(){return{type:""}},e}(r(8372).AbstractPostable);e.Info=a},1409:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Checkbox=void 0;var i=r(6765),s=r(2556),a=r(2165),l=function(t){function e(e,r,n,o){var i=t.call(this,e,"checkbox",r,o)||this;return i.role="menuitemcheckbox",i.variable=e.pool.lookup(n),i.register(),i}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.variable,e.id)},e.prototype.executeAction=function(){this.variable.setValue(!this.variable.getValue()),s.MenuUtil.close(this)},e.prototype.generateSpan=function(){this.span=document.createElement("span"),this.span.textContent="\u2713",this.span.classList.add(a.HtmlClasses.MENUCHECK)},e.prototype.updateAria=function(){this.html.setAttribute("aria-checked",this.variable.getValue()?"true":"false")},e.prototype.updateSpan=function(){this.span.style.display=this.variable.getValue()?"":"none"},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractVariableItem);e.Checkbox=l},9886:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Combo=void 0;var i=r(6765),s=r(2556),a=r(2165),l=r(3205),c=function(t){function e(e,r,n,o){var i=t.call(this,e,"combobox",r,o)||this;return i.role="combobox",i.inputEvent=!1,i.variable=e.pool.lookup(n),i.register(),i}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.variable,e.id)},e.prototype.executeAction=function(){this.variable.setValue(this.input.value,s.MenuUtil.getActiveElement(this))},e.prototype.space=function(e){t.prototype.space.call(this,e),s.MenuUtil.close(this)},e.prototype.focus=function(){t.prototype.focus.call(this),this.input.focus()},e.prototype.unfocus=function(){t.prototype.unfocus.call(this),this.updateSpan()},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this),this.html.classList.add(a.HtmlClasses.MENUCOMBOBOX)},e.prototype.generateSpan=function(){this.span=document.createElement("span"),this.span.classList.add(a.HtmlClasses.MENUINPUTBOX),this.input=document.createElement("input"),this.input.addEventListener("keydown",this.inputKey.bind(this)),this.input.setAttribute("size","10em"),this.input.setAttribute("type","text"),this.input.setAttribute("tabindex","-1"),this.span.appendChild(this.input)},e.prototype.inputKey=function(t){this.bubbleKey(),this.inputEvent=!0},e.prototype.keydown=function(e){if(this.inputEvent&&e.keyCode!==l.KEY.ESCAPE&&e.keyCode!==l.KEY.RETURN)return this.inputEvent=!1,void e.stopPropagation();t.prototype.keydown.call(this,e),e.stopPropagation()},e.prototype.updateAria=function(){},e.prototype.updateSpan=function(){var t;try{t=this.variable.getValue(s.MenuUtil.getActiveElement(this))}catch(e){t=""}this.input.value=t},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractVariableItem);e.Combo=c},3467:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Command=void 0;var i=r(1340),s=r(2556),a=function(t){function e(e,r,n,o){var i=t.call(this,e,"command",r,o)||this;return i.command=n,i}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.action,e.id)},e.prototype.executeAction=function(){try{this.command(s.MenuUtil.getActiveElement(this))}catch(t){s.MenuUtil.error(t,"Illegal command callback.")}s.MenuUtil.close(this)},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractItem);e.Command=a},2965:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Label=void 0;var i=r(1340),s=r(2165),a=function(t){function e(e,r,n){return t.call(this,e,"label",r,n)||this}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.id)},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this),this.html.classList.add(s.HtmlClasses.MENULABEL)},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractItem);e.Label=a},385:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Radio=void 0;var i=r(6765),s=r(2556),a=r(2165),l=function(t){function e(e,r,n,o){var i=t.call(this,e,"radio",r,o)||this;return i.role="menuitemradio",i.variable=e.pool.lookup(n),i.register(),i}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.variable,e.id)},e.prototype.executeAction=function(){this.variable.setValue(this.id),s.MenuUtil.close(this)},e.prototype.generateSpan=function(){this.span=document.createElement("span"),this.span.textContent="\u2713",this.span.classList.add(a.HtmlClasses.MENURADIOCHECK)},e.prototype.updateAria=function(){this.html.setAttribute("aria-checked",this.variable.getValue()===this.id?"true":"false")},e.prototype.updateSpan=function(){this.span.style.display=this.variable.getValue()===this.id?"":"none"},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractVariableItem);e.Radio=l},3463:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Rule=void 0;var i=r(9329),s=r(2165),a=function(t){function e(e){var r=t.call(this,e,"rule")||this;return r.className=s.HtmlClasses.MENUITEM,r.role="separator",r}return o(e,t),e.fromJson=function(t,e,r){return new this(r)},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this);var e=this.html;e.classList.add(s.HtmlClasses.MENURULE),e.setAttribute("aria-orientation","vertical")},e.prototype.addEvents=function(t){},e.prototype.toJson=function(){return{type:"rule"}},e}(i.AbstractEntry);e.Rule=a},7625:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Slider=void 0;var i=r(6765),s=r(2556),a=r(2165),l=r(3205),c=function(t){function e(e,r,n,o){var i=t.call(this,e,"slider",r,o)||this;return i.role="slider",i.labelId="ctx_slideLabel"+s.MenuUtil.counter(),i.valueId="ctx_slideValue"+s.MenuUtil.counter(),i.inputEvent=!1,i.variable=e.pool.lookup(n),i.register(),i}return o(e,t),e.fromJson=function(t,e,r){return new this(r,e.content,e.variable,e.id)},e.prototype.executeAction=function(){this.variable.setValue(this.input.value,s.MenuUtil.getActiveElement(this)),this.update()},e.prototype.space=function(e){t.prototype.space.call(this,e),s.MenuUtil.close(this)},e.prototype.focus=function(){t.prototype.focus.call(this),this.input.focus()},e.prototype.unfocus=function(){t.prototype.unfocus.call(this),this.updateSpan()},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this),this.html.classList.add(a.HtmlClasses.MENUSLIDER),this.valueSpan=document.createElement("span"),this.valueSpan.setAttribute("id",this.valueId),this.valueSpan.classList.add(a.HtmlClasses.SLIDERVALUE),this.html.appendChild(this.valueSpan)},e.prototype.generateSpan=function(){this.span=document.createElement("span"),this.labelSpan=document.createElement("span"),this.labelSpan.setAttribute("id",this.labelId),this.labelSpan.appendChild(this.html.childNodes[0]),this.html.appendChild(this.labelSpan),this.input=document.createElement("input"),this.input.setAttribute("type","range"),this.input.setAttribute("min","0"),this.input.setAttribute("max","100"),this.input.setAttribute("aria-valuemin","0"),this.input.setAttribute("aria-valuemax","100"),this.input.setAttribute("aria-labelledby",this.labelId),this.input.addEventListener("keydown",this.inputKey.bind(this)),this.input.addEventListener("input",this.executeAction.bind(this)),this.input.classList.add(a.HtmlClasses.SLIDERBAR),this.span.appendChild(this.input)},e.prototype.inputKey=function(t){this.inputEvent=!0},e.prototype.mousedown=function(t){t.stopPropagation()},e.prototype.mouseup=function(t){event.stopPropagation()},e.prototype.keydown=function(e){var r=e.keyCode;return r===l.KEY.UP||r===l.KEY.DOWN?(e.preventDefault(),void t.prototype.keydown.call(this,e)):this.inputEvent&&r!==l.KEY.ESCAPE&&r!==l.KEY.RETURN?(this.inputEvent=!1,void e.stopPropagation()):(t.prototype.keydown.call(this,e),void e.stopPropagation())},e.prototype.updateAria=function(){var t=this.variable.getValue();t&&this.input&&(this.input.setAttribute("aria-valuenow",t),this.input.setAttribute("aria-valuetext",t+"%"))},e.prototype.updateSpan=function(){var t;try{t=this.variable.getValue(s.MenuUtil.getActiveElement(this)),this.valueSpan.innerHTML=t+"%"}catch(e){t=""}this.input.value=t},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractVariableItem);e.Slider=c},6186:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.Submenu=void 0;var i=r(1340),s=r(2165),a=function(t){function e(e,r,n){var o=t.call(this,e,"submenu",r,n)||this;return o._submenu=null,o}return o(e,t),e.fromJson=function(t,e,r){var n=e.content,o=e.menu,i=new this(r,n,e.id),s=t.get("subMenu")(t,o,i);return i.submenu=s,i},Object.defineProperty(e.prototype,"submenu",{get:function(){return this._submenu},set:function(t){this._submenu=t},enumerable:!1,configurable:!0}),e.prototype.mouseover=function(t){this.focus(),this.stop(t)},e.prototype.mouseout=function(t){this.stop(t)},e.prototype.unfocus=function(){if(this.submenu.isPosted()){if(this.menu.focused!==this)return t.prototype.unfocus.call(this),void this.menu.unpostSubmenus();this.html.setAttribute("tabindex","-1"),this.html.blur()}else t.prototype.unfocus.call(this)},e.prototype.focus=function(){t.prototype.focus.call(this),this.submenu.isPosted()||this.disabled||this.submenu.post()},e.prototype.executeAction=function(){this.submenu.isPosted()?this.submenu.unpost():this.submenu.post()},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this);var e=this.html;this.span=document.createElement("span"),this.span.textContent="\u25ba",this.span.classList.add(s.HtmlClasses.MENUARROW),e.appendChild(this.span),e.setAttribute("aria-haspopup","true")},e.prototype.left=function(e){this.submenu.isPosted()?this.submenu.unpost():t.prototype.left.call(this,e)},e.prototype.right=function(t){this.submenu.isPosted()?this.submenu.down(t):this.submenu.post()},e.prototype.toJson=function(){return{type:""}},e}(i.AbstractItem);e.Submenu=a},3205:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.KEY=void 0,function(t){t[t.RETURN=13]="RETURN",t[t.ESCAPE=27]="ESCAPE",t[t.SPACE=32]="SPACE",t[t.LEFT=37]="LEFT",t[t.UP=38]="UP",t[t.RIGHT=39]="RIGHT",t[t.DOWN=40]="DOWN"}(e.KEY||(e.KEY={}))},9328:function(t,e,r){var n,o=this&&this.__extends||(n=function(t,e){return n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},n(t,e)},function(t,e){function r(){this.constructor=t}n(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)});Object.defineProperty(e,"__esModule",{value:!0}),e.MenuElement=void 0;var i=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return o(e,t),e.prototype.addAttributes=function(t){for(var e in t)this.html.setAttribute(e,t[e])},Object.defineProperty(e.prototype,"html",{get:function(){return this._html||this.generateHtml(),this._html},set:function(t){this._html=t,this.addEvents(t)},enumerable:!1,configurable:!0}),e.prototype.generateHtml=function(){var t=document.createElement("div");t.classList.add(this.className),t.setAttribute("role",this.role),this.html=t},e.prototype.focus=function(){var t=this.html;t.setAttribute("tabindex","0"),t.focus()},e.prototype.unfocus=function(){var t=this.html;t.hasAttribute("tabindex")&&t.setAttribute("tabindex","-1");try{t.blur()}catch(t){}t.blur()},e}(r(2868).AbstractNavigatable);e.MenuElement=i},1932:function(t,e,r){var n=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")};Object.defineProperty(e,"__esModule",{value:!0}),e.MenuStore=void 0;var o=r(2556),i=r(2165),s=r(3205),a=function(){function t(t){this.menu=t,this.store=[],this._active=null,this.counter=0,this.attachedClass=i.HtmlClasses.ATTACHED+"_"+o.MenuUtil.counter(),this.taborder=!0,this.attrMap={}}return Object.defineProperty(t.prototype,"active",{get:function(){return this._active},set:function(t){do{if(-1!==this.store.indexOf(t)){this._active=t;break}t=t.parentNode}while(t)},enumerable:!1,configurable:!0}),t.prototype.next=function(){var t=this.store.length;if(0===t)return this.active=null,null;var e=this.store.indexOf(this.active);return e=-1===e?0:e0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s},i=this&&this.__values||function(t){var e="function"==typeof Symbol&&Symbol.iterator,r=e&&t[e],n=0;if(r)return r.call(t);if(t&&"number"==typeof t.length)return{next:function(){return t&&n>=t.length&&(t=void 0),{value:t&&t[n++],done:!t}}};throw new TypeError(e?"Object is not iterable.":"Symbol.iterator is not defined.")},s=this&&this.__spread||function(){for(var t=[],e=0;e0)&&!(n=i.next()).done;)s.push(n.value)}catch(t){o={error:t}}finally{try{n&&!n.done&&(r=i.return)&&r.call(i)}finally{if(o)throw o.error}}return s};Object.defineProperty(e,"__esModule",{value:!0}),e.SelectionBox=e.SelectionMenu=void 0;var s=r(2556),a=r(2165),l=r(1484),c=r(4922),u=function(t){function e(e){var r=t.call(this)||this;return r.anchor=e,r.className=a.HtmlClasses.SELECTIONMENU,r.variablePool=r.anchor.menu.pool,r.baseMenu=r.anchor.menu,r}return o(e,t),e.fromJson=function(t,e,r){var n=e.title,o=e.values,i=e.variable,s=new this(r),a=t.get("label")(t,{content:n||"",id:n||"id"},s),l=t.get("rule")(t,{},s),c=o.map((function(e){return t.get("radio")(t,{content:e,variable:i,id:e},s)})),u=[a,l].concat(c);return s.items=u,s},e.prototype.generateHtml=function(){t.prototype.generateHtml.call(this),this.items.forEach((function(t){return t.html.classList.add(a.HtmlClasses.SELECTIONITEM)}))},e.prototype.display=function(){},e.prototype.right=function(t){this.anchor.right(t)},e.prototype.left=function(t){this.anchor.left(t)},e}(l.AbstractMenu);e.SelectionMenu=u;var p=function(t){function e(e,r,n,o){void 0===n&&(n="none"),void 0===o&&(o="vertical");var i=t.call(this,e,null,r)||this;return i.style=n,i.grid=o,i._selections=[],i.prefix="ctxt-selection",i._balanced=!0,i}return o(e,t),e.fromJson=function(t,e,r){var n=e.title,o=e.signature,i=e.selections,s=new this(n,o,e.order,e.grid);s.attachMenu(r);var a=i.map((function(e){return t.get("selectionMenu")(t,e,s)}));return s.selections=a,s},e.prototype.attachMenu=function(t){this.menu=t},Object.defineProperty(e.prototype,"selections",{get:function(){return this._selections},set:function(t){var e=this;this._selections=[],t.forEach((function(t){return e.addSelection(t)}))},enumerable:!1,configurable:!0}),e.prototype.addSelection=function(t){t.anchor=this,this._selections.push(t)},e.prototype.rowDiv=function(t){var e=this,r=document.createElement("div");this.contentDiv.appendChild(r);var n=t.map((function(t){return r.appendChild(t.html),t.html.id||(t.html.id=e.prefix+s.MenuUtil.counter()),t.html.getBoundingClientRect()})),o=n.map((function(t){return t.width})),i=o.reduce((function(t,e){return t+e}),0),l=n.reduce((function(t,e){return Math.max(t,e.height)}),0);return r.classList.add(a.HtmlClasses.SELECTIONDIVIDER),r.setAttribute("style","height: "+l+"px;"),[r,i,l,o]},e.prototype.display=function(){if(t.prototype.display.call(this),this.order(),this.selections.length){for(var e=[],r=0,n=[],o=this.getChunkSize(this.selections.length),s=function(t){var s=a.selections.slice(t,t+o),l=i(a.rowDiv(s),4),c=l[0],u=l[1],p=l[2],h=l[3];e.push(c),r=Math.max(r,u),s.forEach((function(t){return t.html.style.height=p+"px"})),n=a.combineColumn(n,h)},a=this,l=0;ldocument.body.offsetWidth-5&&(i=Math.max(5,i-o-r.offsetWidth+6)),t.prototype.post.call(this,i,s)}},e.prototype.display=function(){this.baseMenu.frame.appendChild(this.html)},e.prototype.setBaseMenu=function(){var t=this;do{t=t.anchor.menu}while(t instanceof e);this.baseMenu=t},e.prototype.left=function(t){this.focused=null,this.anchor.focus()},e.prototype.toJson=function(){return{type:""}},e}(r(1484).AbstractMenu);e.SubMenu=i},3737:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.Variable=void 0;var n=r(2556),o=function(){function t(t,e,r){this._name=t,this.getter=e,this.setter=r,this.items=[]}return t.fromJson=function(t,e,r){var n=new this(e.name,e.getter,e.setter);r.insert(n)},Object.defineProperty(t.prototype,"name",{get:function(){return this._name},enumerable:!1,configurable:!0}),t.prototype.getValue=function(t){try{return this.getter(t)}catch(t){return n.MenuUtil.error(t,"Command of variable "+this.name+" failed."),null}},t.prototype.setValue=function(t,e){try{this.setter(t,e)}catch(t){n.MenuUtil.error(t,"Command of variable "+this.name+" failed.")}this.update()},t.prototype.register=function(t){-1===this.items.indexOf(t)&&this.items.push(t)},t.prototype.unregister=function(t){var e=this.items.indexOf(t);-1!==e&&this.items.splice(e,1)},t.prototype.update=function(){this.items.forEach((function(t){return t.update()}))},t.prototype.registerCallback=function(t){this.items.forEach((function(e){return e.registerCallback(t)}))},t.prototype.unregisterCallback=function(t){this.items.forEach((function(e){return e.unregisterCallback(t)}))},t.prototype.toJson=function(){return{type:"variable",name:this.name,getter:this.getter.toString(),setter:this.setter.toString()}},t}();e.Variable=o},2358:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.VariablePool=void 0;var r=function(){function t(){this.pool={}}return t.prototype.insert=function(t){this.pool[t.name]=t},t.prototype.lookup=function(t){return this.pool[t]},t.prototype.remove=function(t){delete this.pool[t]},t.prototype.update=function(){for(var t in this.pool)this.pool[t].update()},t}();e.VariablePool=r},3921:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractAudioRenderer=void 0;const n=r(5897);e.AbstractAudioRenderer=class{constructor(){this.separator_=" "}setSeparator(t){this.separator_=t}getSeparator(){return"braille"===n.default.getInstance().modality?"":this.separator_}error(t){return null}merge(t){let e="";const r=t.length-1;for(let n,o=0;n=t[o];o++)if(e+=n.speech,odelete t[e])),e.open.forEach((r=>t[r]=e[r]));const r=Object.keys(t);t.open=r},e.sortClose=function(t,e){if(t.length<=1)return t;const r=[];for(let n,o=0;n=e[o],t.length;o++)n.close&&n.close.length&&n.close.forEach((function(e){const n=t.indexOf(e);-1!==n&&(r.unshift(e),t.splice(n,1))}));return r};let a={},l=[];function c(t,e){const r=t[t.length-1];if(r){if(f(e)&&f(r)){if(void 0===r.join)return void(r.span=r.span.concat(e.span));const t=r.span.pop(),n=e.span.shift();return r.span.push(t+r.join+n),r.span=r.span.concat(e.span),void(r.join=e.join)}h(e)&&h(r)?r.pause=s(r.pause,e.pause):t.push(e)}else t.push(e)}function u(t,e){t.rate&&(e.rate=t.rate),t.pitch&&(e.pitch=t.pitch),t.volume&&(e.volume=t.volume)}function p(t){return"object"==typeof t&&t.open}function h(t){return"object"==typeof t&&1===Object.keys(t).length&&Object.keys(t)[0]===o.personalityProps.PAUSE}function f(t){const e=Object.keys(t);return"object"==typeof t&&(1===e.length&&"span"===e[0]||2===e.length&&("span"===e[0]&&"join"===e[1]||"span"===e[1]&&"join"===e[0]))}function d(t,e,r,n,a,l=!1){if(l){const l=t[t.length-1];let c;if(l&&(c=l[o.personalityProps.JOIN]),l&&!e.speech&&a&&h(l)){const t=o.personalityProps.PAUSE;l[t]=s(l[t],a[t]),a=null}if(l&&e.speech&&0===Object.keys(r).length&&f(l)){if(void 0!==c){const t=l.span.pop();e=new i.Span(t.speech+c+e.speech,t.attributes)}l.span.push(e),e=new i.Span("",{}),l[o.personalityProps.JOIN]=n}}0!==Object.keys(r).length&&t.push(r),e.speech&&t.push({span:[e],join:n}),a&&t.push(a)}function m(t,e){if(!e)return t;const r={};for(const n of o.personalityPropList){const o=t[n],i=e[n];if(!o&&!i||o&&i&&o===i)continue;const s=o||0;p(r)||(r.open=[],r.close=[]),o||r.close.push(n),i||r.open.push(n),i&&o&&(r.close.push(n),r.open.push(n)),e[n]=s,r[n]=s,a[n]?a[n].push(s):a[n]=[s]}if(p(r)){let t=r.close.slice();for(;t.length>0;){let o=l.pop();const i=(0,n.setdifference)(o,t);if(t=(0,n.setdifference)(t,o),o=i,0!==t.length){if(0!==o.length){r.close=r.close.concat(o),r.open=r.open.concat(o);for(let t,n=0;t=o[n];n++)r[t]=e[t]}}else 0!==o.length&&l.push(o)}l.push(r.open)}return r}e.personalityMarkup=function(t){a={},l=[];let e=[];const r={};for(let n,i=0;n=t[i];i++){let t=null;const i=n.descriptionSpan(),s=n.personality,a=s[o.personalityProps.JOIN];delete s[o.personalityProps.JOIN],void 0!==s[o.personalityProps.PAUSE]&&(t={[o.personalityProps.PAUSE]:s[o.personalityProps.PAUSE]},delete s[o.personalityProps.PAUSE]);d(e,i,m(s,r),a,t,!0)}return e=e.concat(function(){const t=[];for(let e=l.length-1;e>=0;e--){const r=l[e];if(r.length){const e={open:[],close:[]};for(let t=0;t"string"==typeof t?new c.Span(t,{}):t)),r=m.get(n.default.getInstance().markup);return r?r.merge(e):t.join()},e.finalize=function(t){const e=m.get(n.default.getInstance().markup);return e?e.finalize(t):t},e.error=function(t){const e=m.get(n.default.getInstance().markup);return e?e.error(t):""},e.registerRenderer=function(t,e){m.set(t,e)},e.isXml=function(){return m.get(n.default.getInstance().markup)instanceof f.XmlRenderer}},8639:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.LayoutRenderer=void 0;const n=r(2057),o=r(5740),i=r(4440),s=r(3706),a=r(2456);class l extends a.XmlRenderer{finalize(t){return function(t){c="";const e=o.parseInput(`${t}`);return n.Debugger.getInstance().output(o.formatXml(e.toString())),c=f(e),c}(t)}pause(t){return""}prosodyElement(t,e){return t===i.personalityProps.LAYOUT?`<${e}>`:""}closeTag(t){return``}markup(t){const e=[];let r=[];for(const n of t){if(!n.layout){r.push(n);continue}e.push(this.processContent(r)),r=[];const t=n.layout;t.match(/^begin/)?e.push("<"+t.replace(/^begin/,"")+">"):t.match(/^end/)?e.push(""):console.warn("Something went wrong with layout markup: "+t)}return e.push(this.processContent(r)),e.join("")}processContent(t){const e=[],r=s.personalityMarkup(t);for(let t,n=0;t=r[n];n++)t.span?e.push(this.merge(t.span)):s.isPauseElement(t);return e.join("")}}e.LayoutRenderer=l;let c="";const u={TABLE:function(t){let e=g(t);e.forEach((t=>{t.cells=t.cells.slice(1).slice(0,-1),t.width=t.width.slice(1).slice(0,-1)}));const[r,n]=b(e);return e=v(e,n),_(e,r)},CASES:function(t){let e=g(t);e.forEach((t=>{t.cells=t.cells.slice(0,-1),t.width=t.width.slice(0,-1)}));const[r,n]=b(e);return e=v(e,n),_(e,r)},CAYLEY:function(t){let e=g(t);e.forEach((t=>{t.cells=t.cells.slice(1).slice(0,-1),t.width=t.width.slice(1).slice(0,-1),t.sep=t.sep+t.sep}));const[r,n]=b(e),o={lfence:"",rfence:"",cells:n.map((t=>"\u2810"+new Array(t).join("\u2812"))),width:n,height:1,sep:e[0].sep};return e.splice(1,0,o),e=v(e,n),_(e,r)},MATRIX:function(t){let e=g(t);const[r,n]=b(e);return e=v(e,n),_(e,r)},CELL:f,FENCE:f,ROW:f,FRACTION:function(t){const[e,r,,n,o]=Array.from(t.childNodes),i=p(r),s=p(n),a=m(i),l=m(s);let c=Math.max(a,l);const u=e+new Array(c+1).join("\u2812")+o;return c=u.length,`${x(i,c)}\n${u}\n${x(s,c)}`},NUMERATOR:E,DENOMINATOR:E};function p(t){const e=o.tagName(t),r=u[e];return r?r(t):t.textContent}function h(t,e){if(!t||!e)return t+e;const r=d(t),n=d(e),o=r-n;t=o<0?y(t,n,m(t)):t,e=o>0?y(e,r,m(e)):e;const i=t.split(/\r\n|\r|\n/),s=e.split(/\r\n|\r|\n/),a=[];for(let t=0;tMath.max(e.length,t)),0)}function y(t,e,r){return t=function(t,e){const r=e-d(t);return t+(r>0?new Array(r+1).join("\n"):"")}(t,e),function(t,e){const r=t.split(/\r\n|\r|\n/),n=[];for(const t of r){const r=e-t.length;n.push(t+(r>0?new Array(r+1).join("\u2800"):""))}return n.join("\n")}(t,r)}function g(t){const e=Array.from(t.childNodes),r=[];for(const t of e)t.nodeType===o.NodeType.ELEMENT_NODE&&r.push(O(t));return r}function b(t){const e=t.reduce(((t,e)=>Math.max(e.height,t)),0),r=[];for(let e=0;et.width[e])).reduce(((t,e)=>Math.max(t,e)),0));return[e,r]}function v(t,e){const r=[];for(const n of t){if(0===n.height)continue;const t=[];for(let r=0;rt.lfence+t.cells.join(t.sep)+t.rfence)).join("\n");const r=[];for(const e of t){const t=S(e.sep,e.height);let n=e.cells.shift();for(;e.cells.length;)n=h(n,t),n=h(n,e.cells.shift());n=h(S(e.lfence,e.height),n),n=h(n,S(e.rfence,e.height)),r.push(n),r.push(e.lfence+new Array(m(n)-3).join(e.sep)+e.rfence)}return r.slice(0,-1).join("\n")}function S(t,e){let r="";for(;e;)r+=t+"\n",e--;return r.slice(0,-1)}function M(t){return t.nodeType===o.NodeType.ELEMENT_NODE&&"FENCE"===o.tagName(t)?p(t):""}function O(t){const e=Array.from(t.childNodes),r=M(e[0]),n=M(e[e.length-1]);r&&e.shift(),n&&e.pop();let i="";const s=[];for(const t of e){if(t.nodeType===o.NodeType.TEXT_NODE){i=t.textContent;continue}const e=p(t);s.push(e)}return{lfence:r,rfence:n,sep:i,cells:s,height:s.reduce(((t,e)=>Math.max(d(e),t)),0),width:s.map(m)}}function x(t,e){const r=(e-m(t))/2,[n,o]=Math.floor(r)===r?[r,r]:[Math.floor(r),Math.ceil(r)],i=t.split(/\r\n|\r|\n/),s=[],[a,l]=[new Array(n+1).join("\u2800"),new Array(o+1).join("\u2800")];for(const t of i)s.push(a+t+l);return s.join("\n")}function E(t){const e=t.firstChild,r=f(t);if(e&&e.nodeType===o.NodeType.ELEMENT_NODE){if("ENGLISH"===o.tagName(e))return"\u2830"+r;if("NUMBER"===o.tagName(e))return"\u283c"+r}return r}},182:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.MarkupRenderer=void 0;const n=r(4440),o=r(3921);class i extends o.AbstractAudioRenderer{constructor(){super(...arguments),this.ignoreElements=[n.personalityProps.LAYOUT],this.scaleFunction=null}setScaleFunction(t,e,r,n,o=0){this.scaleFunction=i=>{const s=(i-t)/(e-t),a=r*(1-s)+n*s;return+(Math.round(a+"e+"+o)+"e-"+o)}}applyScaleFunction(t){return this.scaleFunction?this.scaleFunction(t):t}ignoreElement(t){return-1!==this.ignoreElements.indexOf(t)}}e.MarkupRenderer=i},8990:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.PunctuationRenderer=void 0;const n=r(4440),o=r(3921),i=r(3706);class s extends o.AbstractAudioRenderer{markup(t){const e=i.personalityMarkup(t);let r="",o=null,s=!1;for(let t,a=0;t=e[a];a++)i.isMarkupElement(t)||(i.isPauseElement(t)?s&&(o=i.mergePause(o,t,Math.max)):(o&&(r+=this.pause(o[n.personalityProps.PAUSE]),o=null),r+=(s?this.getSeparator():"")+this.merge(t.span),s=!0));return r}pause(t){let e;return e="number"==typeof t?t<=250?"short":t<=500?"medium":"long":t,s.PAUSE_PUNCTUATION.get(e)||""}}e.PunctuationRenderer=s,s.PAUSE_PUNCTUATION=new Map([["short",","],["medium",";"],["long","."]])},6660:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SableRenderer=void 0;const n=r(4440),o=r(2456);class i extends o.XmlRenderer{finalize(t){return''+this.getSeparator()+t+this.getSeparator()+""}pause(t){return''}prosodyElement(t,e){switch(e=this.applyScaleFunction(e),t){case n.personalityProps.PITCH:return'';case n.personalityProps.RATE:return'';case n.personalityProps.VOLUME:return'';default:return"<"+t.toUpperCase()+' VALUE="'+e+'">'}}closeTag(t){return""}}e.SableRenderer=i},9536:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Span=void 0;e.Span=class{constructor(t,e){this.speech=t,this.attributes=e}}},7504:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SsmlRenderer=void 0;const n=r(5897),o=r(4440),i=r(2456);class s extends i.XmlRenderer{finalize(t){return''+this.getSeparator()+t+this.getSeparator()+""}pause(t){return''}prosodyElement(t,e){const r=(e=Math.floor(this.applyScaleFunction(e)))<0?e.toString():"+"+e.toString();return"":'%">')}closeTag(t){return""}}e.SsmlRenderer=s},3757:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SsmlStepRenderer=void 0;const n=r(7504);class o extends n.SsmlRenderer{markup(t){return o.MARKS={},super.markup(t)}merge(t){const e=[];for(let r=0;r'),o.MARKS[i]=!0),1===n.speech.length&&n.speech.match(/[a-zA-Z]/)?e.push(''+n.speech+""):e.push(n.speech)}return e.join(this.getSeparator())}}e.SsmlStepRenderer=o,o.CHARACTER_ATTR="character",o.MARKS={}},4032:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.StringRenderer=void 0;const n=r(3921),o=r(3706);class i extends n.AbstractAudioRenderer{markup(t){let e="";const r=(0,o.personalityMarkup)(t).filter((t=>t.span));if(!r.length)return e;const n=r.length-1;for(let t,o=0;t=r[o];o++){if(t.span&&(e+=this.merge(t.span)),o>=n)continue;const r=t.join;e+=void 0===r?this.getSeparator():r}return e}}e.StringRenderer=i},2456:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.XmlRenderer=void 0;const n=r(5897),o=r(3706),i=r(182);class s extends i.MarkupRenderer{markup(t){this.setScaleFunction(-2,2,-100,100,2);const e=o.personalityMarkup(t),r=[],i=[];for(let t,s=0;t=e[s];s++)if(t.span)r.push(this.merge(t.span));else if(o.isPauseElement(t))r.push(this.pause(t));else{if(t.close.length)for(let e=0;e{r.push(this.prosodyElement(e,t[e])),i.push(e)}))}}return r.join(" ")}}e.XmlRenderer=s},707:function(t,e){function r(t,e){return t?e?t.filter((t=>e.indexOf(t)<0)):t:[]}Object.defineProperty(e,"__esModule",{value:!0}),e.union=e.setdifference=e.interleaveLists=e.removeEmpty=void 0,e.removeEmpty=function(t){return t.filter((t=>t))},e.interleaveLists=function(t,e){const r=[];for(;t.length||e.length;)t.length&&r.push(t.shift()),e.length&&r.push(e.shift());return r},e.setdifference=r,e.union=function(t,e){return t&&e?t.concat(r(e,t)):t||e||[]}},2139:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.loadScript=e.loadMapsForIE_=e.installWGXpath_=e.loadWGXpath_=e.mapsForIE=e.detectEdge=e.detectIE=void 0;const n=r(2315),o=r(5274);function i(t){l(n.default.WGXpath),s(t)}function s(t,e){let r=e||1;"undefined"==typeof wgxpath&&r<10?setTimeout((function(){s(t,r++)}),200):r>=10||(n.default.wgxpath=wgxpath,t?n.default.wgxpath.install({document:document}):n.default.wgxpath.install(),o.xpath.evaluate=document.evaluate,o.xpath.result=XPathResult,o.xpath.createNSResolver=document.createNSResolver)}function a(){l(n.default.mathmapsIePath)}function l(t){const e=n.default.document.createElement("script");e.type="text/javascript",e.src=t,n.default.document.head?n.default.document.head.appendChild(e):n.default.document.body.appendChild(e)}e.detectIE=function(){return"undefined"!=typeof window&&"ActiveXObject"in window&&"clipboardData"in window&&(a(),i(),!0)},e.detectEdge=function(){var t;return"undefined"!=typeof window&&"MSGestureEvent"in window&&null===(null===(t=window.chrome)||void 0===t?void 0:t.loadTimes)&&(document.evaluate=null,i(!0),!0)},e.mapsForIE=null,e.loadWGXpath_=i,e.installWGXpath_=s,e.loadMapsForIE_=a,e.loadScript=l},2057:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.Debugger=void 0;const n=r(2315);class o{constructor(){this.isActive_=!1,this.outputFunction_=console.info,this.stream_=null}static getInstance(){return o.instance=o.instance||new o,o.instance}init(t){t&&this.startDebugFile_(t),this.isActive_=!0}output(...t){this.isActive_&&this.output_(t)}generateOutput(t){this.isActive_&&this.output_(t.apply(t,[]))}exit(t=(()=>{})){this.isActive_&&this.stream_&&this.stream_.end("","",t)}startDebugFile_(t){this.stream_=n.default.fs.createWriteStream(t),this.outputFunction_=function(...t){this.stream_.write(t.join(" ")),this.stream_.write("\n")}.bind(this),this.stream_.on("error",function(t){console.info("Invalid log file. Debug information sent to console."),this.outputFunction_=console.info}.bind(this)),this.stream_.on("finish",(function(){console.info("Finalizing debug file.")}))}output_(t){this.outputFunction_.apply(console.info===this.outputFunction_?console:this.outputFunction_,["Speech Rule Engine Debugger:"].concat(t))}}e.Debugger=o},5740:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.serializeXml=e.cloneNode=e.tagName=e.querySelectorAll=e.querySelectorAllByAttrValue=e.querySelectorAllByAttr=e.formatXml=e.createTextNode=e.createElementNS=e.createElement=e.replaceNode=e.NodeType=e.parseInput=e.XML_ENTITIES=e.trimInput_=e.toArray=void 0;const n=r(5897),o=r(4440),i=r(2315),s=r(5274);function a(t){const e=[];for(let r=0,n=t.length;r[ \f\n\r\t\v\u200b]+<").trim()}function c(t,e){if(!e)return[!1,""];const r=t.match(/^<([^> ]+).*>/),n=e.match(/^<\/([^>]+)>(.*)/);return r&&n&&r[1]===n[1]?[!0,n[2]]:[!1,""]}e.toArray=a,e.trimInput_=l,e.XML_ENTITIES={"<":!0,">":!0,"&":!0,""":!0,"'":!0},e.parseInput=function(t){const e=new i.default.xmldom.DOMParser,r=l(t),a=!!r.match(/&(?!lt|gt|amp|quot|apos)\w+;/g);if(!r)throw new Error("Empty input!");try{const t=e.parseFromString(r,a?"text/html":"text/xml");return n.default.getInstance().mode===o.Mode.HTTP?(s.xpath.currentDocument=t,a?t.body.childNodes[0]:t.documentElement):t.documentElement}catch(t){throw new n.SREError("Illegal input: "+t.message)}},function(t){t[t.ELEMENT_NODE=1]="ELEMENT_NODE",t[t.ATTRIBUTE_NODE=2]="ATTRIBUTE_NODE",t[t.TEXT_NODE=3]="TEXT_NODE",t[t.CDATA_SECTION_NODE=4]="CDATA_SECTION_NODE",t[t.ENTITY_REFERENCE_NODE=5]="ENTITY_REFERENCE_NODE",t[t.ENTITY_NODE=6]="ENTITY_NODE",t[t.PROCESSING_INSTRUCTION_NODE=7]="PROCESSING_INSTRUCTION_NODE",t[t.COMMENT_NODE=8]="COMMENT_NODE",t[t.DOCUMENT_NODE=9]="DOCUMENT_NODE",t[t.DOCUMENT_TYPE_NODE=10]="DOCUMENT_TYPE_NODE",t[t.DOCUMENT_FRAGMENT_NODE=11]="DOCUMENT_FRAGMENT_NODE",t[t.NOTATION_NODE=12]="NOTATION_NODE"}(e.NodeType||(e.NodeType={})),e.replaceNode=function(t,e){t.parentNode&&(t.parentNode.insertBefore(e,t),t.parentNode.removeChild(t))},e.createElement=function(t){return i.default.document.createElement(t)},e.createElementNS=function(t,e){return i.default.document.createElementNS(t,e)},e.createTextNode=function(t){return i.default.document.createTextNode(t)},e.formatXml=function(t){let e="",r=/(>)(<)(\/*)/g,n=0,o=(t=t.replace(r,"$1\r\n$2$3")).split("\r\n");for(r=/(\.)*(<)(\/*)/g,o=o.map((t=>t.replace(r,"$1\r\n$2$3").split("\r\n"))).reduce(((t,e)=>t.concat(e)),[]);o.length;){let t=o.shift();if(!t)continue;let r=0;if(t.match(/^<\w[^>/]*>[^>]+$/)){const e=c(t,o[0]);e[0]?e[1]?(t+=o.shift().slice(0,-e[1].length),e[1].trim()&&o.unshift(e[1])):t+=o.shift():r=1}else if(t.match(/^<\/\w/))0!==n&&(n-=1);else if(t.match(/^<\w[^>]*[^/]>.*$/))r=1;else if(t.match(/^<\w[^>]*\/>.+$/)){const e=t.indexOf(">")+1;t.slice(e).trim()&&o.unshift(),t=t.slice(0,e)}else r=0;e+=new Array(n+1).join(" ")+t+"\r\n",n+=r}return e},e.querySelectorAllByAttr=function(t,e){return t.querySelectorAll?a(t.querySelectorAll(`[${e}]`)):s.evalXPath(`.//*[@${e}]`,t)},e.querySelectorAllByAttrValue=function(t,e,r){return t.querySelectorAll?a(t.querySelectorAll(`[${e}="${r}"]`)):s.evalXPath(`.//*[@${e}="${r}"]`,t)},e.querySelectorAll=function(t,e){return t.querySelectorAll?a(t.querySelectorAll(e)):s.evalXPath(`.//${e}`,t)},e.tagName=function(t){return t.tagName.toUpperCase()},e.cloneNode=function(t){return t.cloneNode(!0)},e.serializeXml=function(t){return(new i.default.xmldom.XMLSerializer).serializeToString(t)}},5897:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.EnginePromise=e.SREError=void 0;const n=r(1676),o=r(4440),i=r(2057),s=r(1377);class a extends Error{constructor(t=""){super(),this.message=t,this.name="SRE Error"}}e.SREError=a;class l{constructor(){this.customLoader=null,this.parsers={},this.comparator=null,this.mode=o.Mode.SYNC,this.init=!0,this.delay=!1,this.comparators={},this.domain="mathspeak",this.style=n.DynamicCstr.DEFAULT_VALUES[n.Axis.STYLE],this._defaultLocale=n.DynamicCstr.DEFAULT_VALUES[n.Axis.LOCALE],this.locale=this.defaultLocale,this.subiso="",this.modality=n.DynamicCstr.DEFAULT_VALUES[n.Axis.MODALITY],this.speech=o.Speech.NONE,this.markup=o.Markup.NONE,this.walker="Table",this.structure=!1,this.ruleSets=[],this.strict=!1,this.isIE=!1,this.isEdge=!1,this.rate="100",this.pprint=!1,this.config=!1,this.rules="",this.prune="",this.evaluator=l.defaultEvaluator,this.defaultParser=new n.DynamicCstrParser(n.DynamicCstr.DEFAULT_ORDER),this.parser=this.defaultParser,this.dynamicCstr=n.DynamicCstr.defaultCstr()}set defaultLocale(t){this._defaultLocale=s.Variables.ensureLocale(t,this._defaultLocale)}get defaultLocale(){return this._defaultLocale}static getInstance(){return l.instance=l.instance||new l,l.instance}static defaultEvaluator(t,e){return t}static evaluateNode(t){return l.nodeEvaluator(t)}getRate(){const t=parseInt(this.rate,10);return isNaN(t)?100:t}setDynamicCstr(t){if(this.defaultLocale&&(n.DynamicCstr.DEFAULT_VALUES[n.Axis.LOCALE]=this.defaultLocale),t){const e=Object.keys(t);for(let r=0;r{void 0!==t[r]&&(e[r]=t[r])};return r("mode"),e.configurate(t),a.default.BINARY_FEATURES.forEach((r=>{void 0!==t[r]&&(e[r]=!!t[r])})),a.default.STRING_FEATURES.forEach(r),t.json&&(c.default.jsonPath=l.makePath(t.json)),t.xpath&&(c.default.WGXpath=t.xpath),e.setCustomLoader(t.custom),function(t){t.isIE=s.detectIE(),t.isEdge=s.detectEdge()}(e),o.setLocale(),e.setDynamicCstr(),e.init?(a.EnginePromise.promises.init=new Promise(((t,e)=>{setTimeout((()=>{t("init")}),10)})),e.init=!1,a.EnginePromise.get()):e.delay?(e.delay=!1,a.EnginePromise.get()):i.loadLocale()}))}},8496:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Event=e.EventType=e.Move=e.KeyCode=void 0,function(t){t[t.ENTER=13]="ENTER",t[t.ESC=27]="ESC",t[t.SPACE=32]="SPACE",t[t.PAGE_UP=33]="PAGE_UP",t[t.PAGE_DOWN=34]="PAGE_DOWN",t[t.END=35]="END",t[t.HOME=36]="HOME",t[t.LEFT=37]="LEFT",t[t.UP=38]="UP",t[t.RIGHT=39]="RIGHT",t[t.DOWN=40]="DOWN",t[t.TAB=9]="TAB",t[t.LESS=188]="LESS",t[t.GREATER=190]="GREATER",t[t.DASH=189]="DASH",t[t.ZERO=48]="ZERO",t[t.ONE=49]="ONE",t[t.TWO=50]="TWO",t[t.THREE=51]="THREE",t[t.FOUR=52]="FOUR",t[t.FIVE=53]="FIVE",t[t.SIX=54]="SIX",t[t.SEVEN=55]="SEVEN",t[t.EIGHT=56]="EIGHT",t[t.NINE=57]="NINE",t[t.A=65]="A",t[t.B=66]="B",t[t.C=67]="C",t[t.D=68]="D",t[t.E=69]="E",t[t.F=70]="F",t[t.G=71]="G",t[t.H=72]="H",t[t.I=73]="I",t[t.J=74]="J",t[t.K=75]="K",t[t.L=76]="L",t[t.M=77]="M",t[t.N=78]="N",t[t.O=79]="O",t[t.P=80]="P",t[t.Q=81]="Q",t[t.R=82]="R",t[t.S=83]="S",t[t.T=84]="T",t[t.U=85]="U",t[t.V=86]="V",t[t.W=87]="W",t[t.X=88]="X",t[t.Y=89]="Y",t[t.Z=90]="Z"}(e.KeyCode||(e.KeyCode={})),e.Move=new Map([[13,"ENTER"],[27,"ESC"],[32,"SPACE"],[33,"PAGE_UP"],[34,"PAGE_DOWN"],[35,"END"],[36,"HOME"],[37,"LEFT"],[38,"UP"],[39,"RIGHT"],[40,"DOWN"],[9,"TAB"],[188,"LESS"],[190,"GREATER"],[189,"DASH"],[48,"ZERO"],[49,"ONE"],[50,"TWO"],[51,"THREE"],[52,"FOUR"],[53,"FIVE"],[54,"SIX"],[55,"SEVEN"],[56,"EIGHT"],[57,"NINE"],[65,"A"],[66,"B"],[67,"C"],[68,"D"],[69,"E"],[70,"F"],[71,"G"],[72,"H"],[73,"I"],[74,"J"],[75,"K"],[76,"L"],[77,"M"],[78,"N"],[79,"O"],[80,"P"],[81,"Q"],[82,"R"],[83,"S"],[84,"T"],[85,"U"],[86,"V"],[87,"W"],[88,"X"],[89,"Y"],[90,"Z"]]),function(t){t.CLICK="click",t.DBLCLICK="dblclick",t.MOUSEDOWN="mousedown",t.MOUSEUP="mouseup",t.MOUSEOVER="mouseover",t.MOUSEOUT="mouseout",t.MOUSEMOVE="mousemove",t.SELECTSTART="selectstart",t.KEYPRESS="keypress",t.KEYDOWN="keydown",t.KEYUP="keyup",t.TOUCHSTART="touchstart",t.TOUCHMOVE="touchmove",t.TOUCHEND="touchend",t.TOUCHCANCEL="touchcancel"}(e.EventType||(e.EventType={}));e.Event=class{constructor(t,e,r){this.src=t,this.type=e,this.callback=r}add(){this.src.addEventListener(this.type,this.callback)}remove(){this.src.removeEventListener(this.type,this.callback)}}},7248:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.localePath=e.makePath=void 0;const n=r(2315);function o(t){return t.match("/$")?t:t+"/"}e.makePath=o,e.localePath=function(t,e="json"){return o(n.default.jsonPath)+t+(e.match(/^\./)?e:"."+e)}},3769:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.KeyProcessor=e.Processor=void 0;const n=r(8496);class o{constructor(t,e){this.name=t,this.process=e.processor,this.postprocess=e.postprocessor||((t,e)=>t),this.processor=this.postprocess?function(t){return this.postprocess(this.process(t),t)}:this.process,this.print=e.print||o.stringify_,this.pprint=e.pprint||this.print}static stringify_(t){return t?t.toString():t}}e.Processor=o,o.LocalState={walker:null,speechGenerator:null,highlighter:null};class i extends o{constructor(t,e){super(t,e),this.key=e.key||i.getKey_}static getKey_(t){return"string"==typeof t?n.KeyCode[t.toUpperCase()]:t}}e.KeyProcessor=i},6499:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.keypress=e.output=e.print=e.process=e.set=void 0;const n=r(8290),o=r(5714),i=r(3090),s=r(4356),a=r(1414),l=r(9552),c=r(9543),u=r(3362),p=r(1204),h=r(5740),f=r(5897),d=r(4440),m=r(3769),y=r(5274),g=new Map;function b(t){g.set(t.name,t)}function v(t){const e=g.get(t);if(!e)throw new f.SREError("Unknown processor "+t);return e}function _(t,e){const r=v(t);try{return r.processor(e)}catch(t){throw new f.SREError("Processing error for expression "+e)}}function S(t,e){const r=v(t);return f.default.getInstance().pprint?r.pprint(e):r.print(e)}e.set=b,e.process=_,e.print=S,e.output=function(t,e){const r=v(t);try{const t=r.processor(e);return f.default.getInstance().pprint?r.pprint(t):r.print(t)}catch(t){throw new f.SREError("Processing error for expression "+e)}},e.keypress=function(t,e){const r=v(t),n=r instanceof m.KeyProcessor?r.key(e):e,o=r.processor(n);return f.default.getInstance().pprint?r.pprint(o):r.print(o)},b(new m.Processor("semantic",{processor:function(t){const e=h.parseInput(t);return a.xmlTree(e)},postprocessor:function(t,e){const r=f.default.getInstance().speech;if(r===d.Speech.NONE)return t;const o=h.cloneNode(t);let i=c.computeMarkup(o);if(r===d.Speech.SHALLOW)return t.setAttribute("speech",n.finalize(i)),t;const s=y.evalXPath(".//*[@id]",t),a=y.evalXPath(".//*[@id]",o);for(let t,e,r=0;t=s[r],e=a[r];r++)i=c.computeMarkup(e),t.setAttribute("speech",n.finalize(i));return t},pprint:function(t){return h.formatXml(t.toString())}})),b(new m.Processor("speech",{processor:function(t){const e=h.parseInput(t),r=a.xmlTree(e),o=c.computeSpeech(r);return n.finalize(n.markup(o))},pprint:function(t){const e=t.toString();return n.isXml()?h.formatXml(e):e}})),b(new m.Processor("json",{processor:function(t){const e=h.parseInput(t);return a.getTree(e).toJson()},postprocessor:function(t,e){const r=f.default.getInstance().speech;if(r===d.Speech.NONE)return t;const o=h.parseInput(e),i=a.xmlTree(o),s=c.computeMarkup(i);if(r===d.Speech.SHALLOW)return t.stree.speech=n.finalize(s),t;const l=t=>{const e=y.evalXPath(`.//*[@id=${t.id}]`,i)[0],r=c.computeMarkup(e);t.speech=n.finalize(r),t.children&&t.children.forEach(l)};return l(t.stree),t},print:function(t){return JSON.stringify(t)},pprint:function(t){return JSON.stringify(t,null,2)}})),b(new m.Processor("description",{processor:function(t){const e=h.parseInput(t),r=a.xmlTree(e);return c.computeSpeech(r)},print:function(t){return JSON.stringify(t)},pprint:function(t){return JSON.stringify(t,null,2)}})),b(new m.Processor("enriched",{processor:function(t){return o.semanticMathmlSync(t)},postprocessor:function(t,e){const r=p.getSemanticRoot(t);let n;switch(f.default.getInstance().speech){case d.Speech.NONE:break;case d.Speech.SHALLOW:n=l.generator("Adhoc"),n.getSpeech(r,t);break;case d.Speech.DEEP:n=l.generator("Tree"),n.getSpeech(t,t)}return t},pprint:function(t){return h.formatXml(t.toString())}})),b(new m.Processor("walker",{processor:function(t){const e=l.generator("Node");m.Processor.LocalState.speechGenerator=e,e.setOptions({modality:f.default.getInstance().modality,locale:f.default.getInstance().locale,domain:f.default.getInstance().domain,style:f.default.getInstance().style}),m.Processor.LocalState.highlighter=i.highlighter({color:"black"},{color:"white"},{renderer:"NativeMML"});const r=_("enriched",t),n=S("enriched",r);return m.Processor.LocalState.walker=u.walker(f.default.getInstance().walker,r,e,m.Processor.LocalState.highlighter,n),m.Processor.LocalState.walker},print:function(t){return m.Processor.LocalState.walker.speech()}})),b(new m.KeyProcessor("move",{processor:function(t){if(!m.Processor.LocalState.walker)return null;return!1===m.Processor.LocalState.walker.move(t)?n.error(t):m.Processor.LocalState.walker.speech()}})),b(new m.Processor("number",{processor:function(t){const e=parseInt(t,10);return isNaN(e)?"":s.LOCALE.NUMBERS.numberToWords(e)}})),b(new m.Processor("ordinal",{processor:function(t){const e=parseInt(t,10);return isNaN(e)?"":s.LOCALE.NUMBERS.wordOrdinal(e)}})),b(new m.Processor("numericOrdinal",{processor:function(t){const e=parseInt(t,10);return isNaN(e)?"":s.LOCALE.NUMBERS.numericOrdinal(e)}})),b(new m.Processor("vulgar",{processor:function(t){const[e,r]=t.split("/").map((t=>parseInt(t,10)));return isNaN(e)||isNaN(r)?"":_("speech",`${e}${r}`)}}))},2998:function(t,e,r){var n=this&&this.__awaiter||function(t,e,r,n){return new(r||(r=Promise))((function(o,i){function s(t){try{l(n.next(t))}catch(t){i(t)}}function a(t){try{l(n.throw(t))}catch(t){i(t)}}function l(t){var e;t.done?o(t.value):(e=t.value,e instanceof r?e:new r((function(t){t(e)}))).then(s,a)}l((n=n.apply(t,e||[])).next())}))};Object.defineProperty(e,"__esModule",{value:!0}),e.localePath=e.exit=e.move=e.walk=e.processFile=e.file=e.vulgar=e.numericOrdinal=e.ordinal=e.number=e.toEnriched=e.toDescription=e.toJson=e.toSemantic=e.toSpeech=e.localeLoader=e.engineReady=e.engineSetup=e.setupEngine=e.version=void 0;const o=r(5897),i=r(6828),s=r(4440),a=r(7248),l=r(6499),c=r(2315),u=r(1377),p=r(6141);function h(t){return n(this,void 0,void 0,(function*(){return(0,i.setup)(t)}))}function f(t,e){return l.process(t,e)}function d(t,e,r){switch(o.default.getInstance().mode){case s.Mode.ASYNC:return function(t,e,r){return n(this,void 0,void 0,(function*(){const n=yield c.default.fs.promises.readFile(e,{encoding:"utf8"}),i=l.output(t,n);if(r)try{c.default.fs.promises.writeFile(r,i)}catch(t){throw new o.SREError("Can not write to file: "+r)}return i}))}(t,e,r);case s.Mode.SYNC:return function(t,e,r){const n=function(t){let e;try{e=c.default.fs.readFileSync(t,{encoding:"utf8"})}catch(e){throw new o.SREError("Can not open file: "+t)}return e}(e),i=l.output(t,n);if(r)try{c.default.fs.writeFileSync(r,i)}catch(t){throw new o.SREError("Can not write to file: "+r)}return i}(t,e,r);default:throw new o.SREError(`Can process files in ${o.default.getInstance().mode} mode`)}}e.version=u.Variables.VERSION,e.setupEngine=h,e.engineSetup=function(){const t=["mode"].concat(o.default.STRING_FEATURES,o.default.BINARY_FEATURES),e=o.default.getInstance(),r={};return t.forEach((function(t){r[t]=e[t]})),r.json=c.default.jsonPath,r.xpath=c.default.WGXpath,r.rules=e.ruleSets.slice(),r},e.engineReady=function(){return n(this,void 0,void 0,(function*(){return h({}).then((()=>o.EnginePromise.getall()))}))},e.localeLoader=p.standardLoader,e.toSpeech=function(t){return f("speech",t)},e.toSemantic=function(t){return f("semantic",t)},e.toJson=function(t){return f("json",t)},e.toDescription=function(t){return f("description",t)},e.toEnriched=function(t){return f("enriched",t)},e.number=function(t){return f("number",t)},e.ordinal=function(t){return f("ordinal",t)},e.numericOrdinal=function(t){return f("numericOrdinal",t)},e.vulgar=function(t){return f("vulgar",t)},e.file={},e.file.toSpeech=function(t,e){return d("speech",t,e)},e.file.toSemantic=function(t,e){return d("semantic",t,e)},e.file.toJson=function(t,e){return d("json",t,e)},e.file.toDescription=function(t,e){return d("description",t,e)},e.file.toEnriched=function(t,e){return d("enriched",t,e)},e.processFile=d,e.walk=function(t){return l.output("walker",t)},e.move=function(t){return l.keypress("move",t)},e.exit=function(t){const e=t||0;o.EnginePromise.getall().then((()=>process.exit(e)))},e.localePath=a.localePath,c.default.documentSupported?h({mode:s.Mode.HTTP}).then((()=>h({}))):h({mode:s.Mode.SYNC}).then((()=>h({mode:s.Mode.ASYNC})))},2315:function(__unused_webpack_module,exports,__webpack_require__){var __dirname="/";Object.defineProperty(exports,"__esModule",{value:!0});const variables_1=__webpack_require__(1377);class SystemExternal{static extRequire(library){if("undefined"!=typeof process){const nodeRequire=eval("require");return nodeRequire(library)}return null}}exports.default=SystemExternal,SystemExternal.windowSupported=!("undefined"==typeof window),SystemExternal.documentSupported=SystemExternal.windowSupported&&!(void 0===window.document),SystemExternal.xmldom=SystemExternal.documentSupported?window:SystemExternal.extRequire("xmldom-sre"),SystemExternal.document=SystemExternal.documentSupported?window.document:(new SystemExternal.xmldom.DOMImplementation).createDocument("","",0),SystemExternal.xpath=SystemExternal.documentSupported?document:function(){const t={document:{},XPathResult:{}};return SystemExternal.extRequire("wicked-good-xpath").install(t),t.document.XPathResult=t.XPathResult,t.document}(),SystemExternal.mathmapsIePath="https://cdn.jsdelivr.net/npm/sre-mathmaps-ie@"+variables_1.Variables.VERSION+"mathmaps_ie.js",SystemExternal.commander=SystemExternal.documentSupported?null:SystemExternal.extRequire("commander"),SystemExternal.fs=SystemExternal.documentSupported?null:SystemExternal.extRequire("fs"),SystemExternal.url=variables_1.Variables.url,SystemExternal.jsonPath=(SystemExternal.documentSupported?SystemExternal.url:process.env.SRE_JSON_PATH||__webpack_require__.g.SRE_JSON_PATH||__dirname+"/mathmaps")+"/",SystemExternal.WGXpath=variables_1.Variables.WGXpath,SystemExternal.wgxpath=null},1377:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Variables=void 0;class r{static ensureLocale(t,e){return r.LOCALES.get(t)?t:(console.error(`Locale ${t} does not exist! Using ${r.LOCALES.get(e)} instead.`),e)}}e.Variables=r,r.VERSION="4.0.6",r.LOCALES=new Map([["ca","Catalan"],["da","Danish"],["de","German"],["en","English"],["es","Spanish"],["fr","French"],["hi","Hindi"],["it","Italian"],["nb","Bokm\xe5l"],["nn","Nynorsk"],["sv","Swedish"],["nemeth","Nemeth"]]),r.mathjaxVersion="3.2.1",r.url="https://cdn.jsdelivr.net/npm/speech-rule-engine@"+r.VERSION+"/lib/mathmaps",r.WGXpath="https://cdn.jsdelivr.net/npm/wicked-good-xpath@1.3.0/dist/wgxpath.install.js"},5274:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.updateEvaluator=e.evaluateString=e.evaluateBoolean=e.getLeafNodes=e.evalXPath=e.resolveNameSpace=e.xpath=void 0;const n=r(5897),o=r(4440),i=r(2315);function s(){return"undefined"!=typeof XPathResult}e.xpath={currentDocument:null,evaluate:s()?document.evaluate:i.default.xpath.evaluate,result:s()?XPathResult:i.default.xpath.XPathResult,createNSResolver:s()?document.createNSResolver:i.default.xpath.createNSResolver};const a={xhtml:"http://www.w3.org/1999/xhtml",mathml:"http://www.w3.org/1998/Math/MathML",mml:"http://www.w3.org/1998/Math/MathML",svg:"http://www.w3.org/2000/svg"};function l(t){return a[t]||null}e.resolveNameSpace=l;class c{constructor(){this.lookupNamespaceURI=l}}function u(t,r,i){return n.default.getInstance().mode!==o.Mode.HTTP||n.default.getInstance().isIE||n.default.getInstance().isEdge?e.xpath.evaluate(t,r,new c,i,null):e.xpath.currentDocument.evaluate(t,r,l,i,null)}function p(t,r){let n;try{n=u(t,r,e.xpath.result.ORDERED_NODE_ITERATOR_TYPE)}catch(t){return[]}const o=[];for(let t=n.iterateNext();t;t=n.iterateNext())o.push(t);return o}e.evalXPath=p,e.getLeafNodes=function(t){return p(".//*[count(*)=0]",t)},e.evaluateBoolean=function(t,r){let n;try{n=u(t,r,e.xpath.result.BOOLEAN_TYPE)}catch(t){return!1}return n.booleanValue},e.evaluateString=function(t,r){let n;try{n=u(t,r,e.xpath.result.STRING_TYPE)}catch(t){return""}return n.stringValue},e.updateEvaluator=function(t){if(n.default.getInstance().mode!==o.Mode.HTTP)return;let r=t;for(;r&&!r.evaluate;)r=r.parentNode;r&&r.evaluate?e.xpath.currentDocument=r:t.ownerDocument&&(e.xpath.currentDocument=t.ownerDocument)}},9268:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractEnrichCase=void 0;e.AbstractEnrichCase=class{constructor(t){this.semantic=t}}},6061:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseBinomial=void 0;const n=r(5740),o=r(9268),i=r(5452),s=r(2298);class a extends o.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){return!t.mathmlTree&&"line"===t.type&&"binomial"===t.role}getMathml(){if(!this.semantic.childNodes.length)return this.mml;const t=this.semantic.childNodes[0];if(this.mml=(0,i.walkTree)(t),this.mml.hasAttribute(s.Attribute.TYPE)){const t=n.createElement("mrow");t.setAttribute(s.Attribute.ADDED,"true"),n.replaceNode(this.mml,t),t.appendChild(this.mml),this.mml=t}return(0,s.setAttributes)(this.mml,this.semantic),this.mml}}e.CaseBinomial=a},5765:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseDoubleScript=void 0;const n=r(5740),o=r(9268),i=r(5452),s=r(2298);class a extends o.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){if(!t.mathmlTree||!t.childNodes.length)return!1;const e=n.tagName(t.mathmlTree),r=t.childNodes[0].role;return"MSUBSUP"===e&&"subsup"===r||"MUNDEROVER"===e&&"underover"===r}getMathml(){const t=this.semantic.childNodes[0],e=t.childNodes[0],r=this.semantic.childNodes[1],n=t.childNodes[1],o=i.walkTree(r),a=i.walkTree(e),l=i.walkTree(n);return(0,s.setAttributes)(this.mml,this.semantic),this.mml.setAttribute(s.Attribute.CHILDREN,(0,s.makeIdList)([e,n,r])),[a,l,o].forEach((t=>i.getInnerNode(t).setAttribute(s.Attribute.PARENT,this.mml.getAttribute(s.Attribute.ID)))),this.mml.setAttribute(s.Attribute.TYPE,t.role),i.addCollapsedAttribute(this.mml,[this.semantic.id,[t.id,e.id,n.id],r.id]),this.mml}}e.CaseDoubleScript=a},7251:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseEmbellished=void 0;const n=r(5740),o=r(5952),i=r(9268),s=r(5765),a=r(7014),l=r(6887),c=r(5452),u=r(2298);class p extends i.AbstractEnrichCase{constructor(t){super(t),this.fenced=null,this.fencedMml=null,this.fencedMmlNodes=[],this.ofence=null,this.ofenceMml=null,this.ofenceMap={},this.cfence=null,this.cfenceMml=null,this.cfenceMap={},this.parentCleanup=[]}static test(t){return!(!t.mathmlTree||!t.fencePointer||t.mathmlTree.getAttribute("data-semantic-type"))}static makeEmptyNode_(t){const e=n.createElement("mrow"),r=new o.SemanticNode(t);return r.type="empty",r.mathmlTree=e,r}static fencedMap_(t,e){e[t.id]=t.mathmlTree,t.embellished&&p.fencedMap_(t.childNodes[0],e)}getMathml(){this.getFenced_(),this.fencedMml=c.walkTree(this.fenced),this.getFencesMml_(),"empty"!==this.fenced.type||this.fencedMml.parentNode||(this.fencedMml.setAttribute(u.Attribute.ADDED,"true"),this.cfenceMml.parentNode.insertBefore(this.fencedMml,this.cfenceMml)),this.getFencedMml_();return this.rewrite_()}fencedElement(t){return"fenced"===t.type||"matrix"===t.type||"vector"===t.type}getFenced_(){let t=this.semantic;for(;!this.fencedElement(t);)t=t.childNodes[0];this.fenced=t.childNodes[0],this.ofence=t.contentNodes[0],this.cfence=t.contentNodes[1],p.fencedMap_(this.ofence,this.ofenceMap),p.fencedMap_(this.cfence,this.cfenceMap)}getFencedMml_(){let t=this.ofenceMml.nextSibling;for(t=t===this.fencedMml?t:this.fencedMml;t&&t!==this.cfenceMml;)this.fencedMmlNodes.push(t),t=t.nextSibling}getFencesMml_(){let t=this.semantic;const e=Object.keys(this.ofenceMap),r=Object.keys(this.cfenceMap);for(;!(this.ofenceMml&&this.cfenceMml||t===this.fenced);)-1===e.indexOf(t.fencePointer)||this.ofenceMml||(this.ofenceMml=t.mathmlTree),-1===r.indexOf(t.fencePointer)||this.cfenceMml||(this.cfenceMml=t.mathmlTree),t=t.childNodes[0];this.ofenceMml||(this.ofenceMml=this.ofence.mathmlTree),this.cfenceMml||(this.cfenceMml=this.cfence.mathmlTree),this.ofenceMml&&(this.ofenceMml=c.ascendNewNode(this.ofenceMml)),this.cfenceMml&&(this.cfenceMml=c.ascendNewNode(this.cfenceMml))}rewrite_(){let t=this.semantic,e=null;const r=this.introduceNewLayer_();for((0,u.setAttributes)(r,this.fenced.parent);!this.fencedElement(t);){const o=t.mathmlTree,i=this.specialCase_(t,o);if(i)t=i;else{(0,u.setAttributes)(o,t);const e=[];for(let r,n=1;r=t.childNodes[n];n++)e.push(c.walkTree(r));t=t.childNodes[0]}const s=n.createElement("dummy"),a=o.childNodes[0];n.replaceNode(o,s),n.replaceNode(r,o),n.replaceNode(o.childNodes[0],r),n.replaceNode(s,a),e||(e=o)}return c.walkTree(this.ofence),c.walkTree(this.cfence),this.cleanupParents_(),e||r}specialCase_(t,e){const r=n.tagName(e);let o,i=null;if("MSUBSUP"===r?(i=t.childNodes[0],o=s.CaseDoubleScript):"MMULTISCRIPTS"===r&&("superscript"===t.type||"subscript"===t.type?o=a.CaseMultiscripts:"tensor"===t.type&&(o=l.CaseTensor),i=o&&t.childNodes[0]&&"subsup"===t.childNodes[0].role?t.childNodes[0]:t),!i)return null;const c=i.childNodes[0],u=p.makeEmptyNode_(c.id);return i.childNodes[0]=u,e=new o(t).getMathml(),i.childNodes[0]=c,this.parentCleanup.push(e),i.childNodes[0]}introduceNewLayer_(){const t=this.fullFence(this.ofenceMml),e=this.fullFence(this.cfenceMml);let r=n.createElement("mrow");if(n.replaceNode(this.fencedMml,r),this.fencedMmlNodes.forEach((t=>r.appendChild(t))),r.insertBefore(t,this.fencedMml),r.appendChild(e),!r.parentNode){const t=n.createElement("mrow");for(;r.childNodes.length>0;)t.appendChild(r.childNodes[0]);r.appendChild(t),r=t}return r}fullFence(t){const e=this.fencedMml.parentNode;let r=t;for(;r.parentNode&&r.parentNode!==e;)r=r.parentNode;return r}cleanupParents_(){this.parentCleanup.forEach((function(t){const e=t.childNodes[1].getAttribute(u.Attribute.PARENT);t.childNodes[0].setAttribute(u.Attribute.PARENT,e)}))}}e.CaseEmbellished=p},6265:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseLimit=void 0;const n=r(5740),o=r(9268),i=r(5452),s=r(2298);class a extends o.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){if(!t.mathmlTree||!t.childNodes.length)return!1;const e=n.tagName(t.mathmlTree),r=t.type;return("limupper"===r||"limlower"===r)&&("MSUBSUP"===e||"MUNDEROVER"===e)||"limboth"===r&&("MSUB"===e||"MUNDER"===e||"MSUP"===e||"MOVER"===e)}static walkTree_(t){t&&i.walkTree(t)}getMathml(){const t=this.semantic.childNodes;return"limboth"!==this.semantic.type&&this.mml.childNodes.length>=3&&(this.mml=i.introduceNewLayer([this.mml],this.semantic)),(0,s.setAttributes)(this.mml,this.semantic),t[0].mathmlTree||(t[0].mathmlTree=this.semantic.mathmlTree),t.forEach(a.walkTree_),this.mml}}e.CaseLimit=a},6514:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseLine=void 0;const n=r(9268),o=r(5452),i=r(2298);class s extends n.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){return!!t.mathmlTree&&"line"===t.type}getMathml(){return this.semantic.contentNodes.length&&o.walkTree(this.semantic.contentNodes[0]),this.semantic.childNodes.length&&o.walkTree(this.semantic.childNodes[0]),(0,i.setAttributes)(this.mml,this.semantic),this.mml}}e.CaseLine=s},6839:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseMultiindex=void 0;const n=r(5740),o=r(9268),i=r(5452),s=r(2298);class a extends o.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static multiscriptIndex(t){return"punctuated"===t.type&&"dummy"===t.contentNodes[0].role?i.collapsePunctuated(t):(i.walkTree(t),t.id)}static createNone_(t){const e=n.createElement("none");return t&&(0,s.setAttributes)(e,t),e.setAttribute(s.Attribute.ADDED,"true"),e}completeMultiscript(t,e){const r=n.toArray(this.mml.childNodes).slice(1);let o=0;const l=t=>{for(let e,n=0;e=t[n];n++){const t=r[o];if(t&&e===parseInt(i.getInnerNode(t).getAttribute(s.Attribute.ID)))i.getInnerNode(t).setAttribute(s.Attribute.PARENT,this.semantic.id.toString()),o++;else{const r=this.semantic.querySelectorAll((t=>t.id===e));this.mml.insertBefore(a.createNone_(r[0]),t||null)}}};l(t),r[o]&&"MPRESCRIPTS"!==n.tagName(r[o])?this.mml.insertBefore(r[o],n.createElement("mprescripts")):o++,l(e)}}e.CaseMultiindex=a},7014:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseMultiscripts=void 0;const n=r(5740),o=r(5656),i=r(6839),s=r(5452),a=r(2298);class l extends i.CaseMultiindex{static test(t){if(!t.mathmlTree)return!1;return"MMULTISCRIPTS"===n.tagName(t.mathmlTree)&&("superscript"===t.type||"subscript"===t.type)}constructor(t){super(t)}getMathml(){let t,e,r;if((0,a.setAttributes)(this.mml,this.semantic),this.semantic.childNodes[0]&&"subsup"===this.semantic.childNodes[0].role){const n=this.semantic.childNodes[0];t=n.childNodes[0],e=i.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[1]),r=i.CaseMultiindex.multiscriptIndex(n.childNodes[1]);const l=[this.semantic.id,[n.id,t.id,r],e];s.addCollapsedAttribute(this.mml,l),this.mml.setAttribute(a.Attribute.TYPE,n.role),this.completeMultiscript(o.SemanticSkeleton.interleaveIds(r,e),[])}else{t=this.semantic.childNodes[0],e=i.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[1]);const r=[this.semantic.id,t.id,e];s.addCollapsedAttribute(this.mml,r)}const n=o.SemanticSkeleton.collapsedLeafs(r||[],e),l=s.walkTree(t);return s.getInnerNode(l).setAttribute(a.Attribute.PARENT,this.semantic.id.toString()),n.unshift(t.id),this.mml.setAttribute(a.Attribute.CHILDREN,n.join(",")),this.mml}}e.CaseMultiscripts=l},3416:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseProof=void 0;const n=r(9268),o=r(5452),i=r(2298);class s extends n.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){return!!t.mathmlTree&&("inference"===t.type||"premises"===t.type)}getMathml(){return this.semantic.childNodes.length?(this.semantic.contentNodes.forEach((function(t){o.walkTree(t),(0,i.setAttributes)(t.mathmlTree,t)})),this.semantic.childNodes.forEach((function(t){o.walkTree(t)})),(0,i.setAttributes)(this.mml,this.semantic),this.mml.getAttribute("data-semantic-id")===this.mml.getAttribute("data-semantic-parent")&&this.mml.removeAttribute("data-semantic-parent"),this.mml):this.mml}}e.CaseProof=s},5699:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseTable=void 0;const n=r(5740),o=r(9268),i=r(5452),s=r(2298);class a extends o.AbstractEnrichCase{constructor(t){super(t),this.inner=[],this.mml=t.mathmlTree}static test(t){return"matrix"===t.type||"vector"===t.type||"cases"===t.type}getMathml(){const t=i.cloneContentNode(this.semantic.contentNodes[0]),e=this.semantic.contentNodes[1]?i.cloneContentNode(this.semantic.contentNodes[1]):null;if(this.inner=this.semantic.childNodes.map(i.walkTree),this.mml)if("MFENCED"===n.tagName(this.mml)){const r=this.mml.childNodes;this.mml.insertBefore(t,r[0]||null),e&&this.mml.appendChild(e),this.mml=i.rewriteMfenced(this.mml)}else{const r=[t,this.mml];e&&r.push(e),this.mml=i.introduceNewLayer(r,this.semantic)}else this.mml=i.introduceNewLayer([t].concat(this.inner,[e]),this.semantic);return(0,s.setAttributes)(this.mml,this.semantic),this.mml}}e.CaseTable=a},6887:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseTensor=void 0;const n=r(5656),o=r(6839),i=r(5452),s=r(2298);class a extends o.CaseMultiindex{static test(t){return!!t.mathmlTree&&"tensor"===t.type}constructor(t){super(t)}getMathml(){i.walkTree(this.semantic.childNodes[0]);const t=o.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[1]),e=o.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[2]),r=o.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[3]),a=o.CaseMultiindex.multiscriptIndex(this.semantic.childNodes[4]);(0,s.setAttributes)(this.mml,this.semantic);const l=[this.semantic.id,this.semantic.childNodes[0].id,t,e,r,a];i.addCollapsedAttribute(this.mml,l);const c=n.SemanticSkeleton.collapsedLeafs(t,e,r,a);return c.unshift(this.semantic.childNodes[0].id),this.mml.setAttribute(s.Attribute.CHILDREN,c.join(",")),this.completeMultiscript(n.SemanticSkeleton.interleaveIds(r,a),n.SemanticSkeleton.interleaveIds(t,e)),this.mml}}e.CaseTensor=a},9236:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CaseText=void 0;const n=r(9268),o=r(5452),i=r(2298);class s extends n.AbstractEnrichCase{constructor(t){super(t),this.mml=t.mathmlTree}static test(t){return"punctuated"===t.type&&("text"===t.role||t.contentNodes.every((t=>"dummy"===t.role)))}getMathml(){const t=[],e=o.collapsePunctuated(this.semantic,t);return this.mml=o.introduceNewLayer(t,this.semantic),(0,i.setAttributes)(this.mml,this.semantic),this.mml.removeAttribute(i.Attribute.CONTENT),o.addCollapsedAttribute(this.mml,e),this.mml}}e.CaseText=s},5714:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.prepareMmlString=e.testTranslation__=e.semanticMathml=e.semanticMathmlSync=e.semanticMathmlNode=void 0;const n=r(2057),o=r(5740),i=r(5897),s=r(1414),a=r(5452),l=r(2298);function c(t){const e=o.cloneNode(t),r=s.getTree(e);return a.enrich(e,r)}function u(t){return c(o.parseInput(t))}function p(t){return t.match(/^$/)||(t+=""),t}r(1513),e.semanticMathmlNode=c,e.semanticMathmlSync=u,e.semanticMathml=function(t,e){i.EnginePromise.getall().then((()=>{const r=o.parseInput(t);e(c(r))}))},e.testTranslation__=function(t){n.Debugger.getInstance().init();const e=u(p(t)).toString();return(0,l.removeAttributePrefix)(e),n.Debugger.getInstance().exit(),e},e.prepareMmlString=p},2298:function(t,e){var r;function n(t){return t.map((function(t){return t.id})).join(",")}function o(t,e){const n=[];"mglyph"===e.role&&n.push("image"),e.attributes.href&&n.push("link"),n.length&&t.setAttribute(r.POSTFIX,n.join(" "))}Object.defineProperty(e,"__esModule",{value:!0}),e.addPrefix=e.removeAttributePrefix=e.setPostfix=e.setAttributes=e.makeIdList=e.EnrichAttributes=e.Attribute=e.Prefix=void 0,e.Prefix="data-semantic-",function(t){t.ADDED="data-semantic-added",t.ALTERNATIVE="data-semantic-alternative",t.CHILDREN="data-semantic-children",t.COLLAPSED="data-semantic-collapsed",t.CONTENT="data-semantic-content",t.EMBELLISHED="data-semantic-embellished",t.FENCEPOINTER="data-semantic-fencepointer",t.FONT="data-semantic-font",t.ID="data-semantic-id",t.ANNOTATION="data-semantic-annotation",t.OPERATOR="data-semantic-operator",t.OWNS="data-semantic-owns",t.PARENT="data-semantic-parent",t.POSTFIX="data-semantic-postfix",t.PREFIX="data-semantic-prefix",t.ROLE="data-semantic-role",t.SPEECH="data-semantic-speech",t.STRUCTURE="data-semantic-structure",t.TYPE="data-semantic-type"}(r=e.Attribute||(e.Attribute={})),e.EnrichAttributes=[r.ADDED,r.ALTERNATIVE,r.CHILDREN,r.COLLAPSED,r.CONTENT,r.EMBELLISHED,r.FENCEPOINTER,r.FONT,r.ID,r.ANNOTATION,r.OPERATOR,r.OWNS,r.PARENT,r.POSTFIX,r.PREFIX,r.ROLE,r.SPEECH,r.STRUCTURE,r.TYPE],e.makeIdList=n,e.setAttributes=function(t,i){t.setAttribute(r.TYPE,i.type);const s=i.allAttributes();for(let r,n=0;r=s[n];n++)t.setAttribute(e.Prefix+r[0].toLowerCase(),r[1]);i.childNodes.length&&t.setAttribute(r.CHILDREN,n(i.childNodes)),i.contentNodes.length&&t.setAttribute(r.CONTENT,n(i.contentNodes)),i.parent&&t.setAttribute(r.PARENT,i.parent.id.toString()),o(t,i)},e.setPostfix=o,e.removeAttributePrefix=function(t){return t.toString().replace(new RegExp(e.Prefix,"g"),"")},e.addPrefix=function(t){return e.Prefix+t}},3532:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.factory=e.getCase=void 0,e.getCase=function(t){for(let r,n=0;r=e.factory[n];n++)if(r.test(t))return r.constr(t);return null},e.factory=[]},1513:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(6061),o=r(5765),i=r(7251),s=r(6265),a=r(6514),l=r(7014),c=r(3416),u=r(5699),p=r(6887),h=r(9236);r(3532).factory.push({test:s.CaseLimit.test,constr:t=>new s.CaseLimit(t)},{test:i.CaseEmbellished.test,constr:t=>new i.CaseEmbellished(t)},{test:o.CaseDoubleScript.test,constr:t=>new o.CaseDoubleScript(t)},{test:p.CaseTensor.test,constr:t=>new p.CaseTensor(t)},{test:l.CaseMultiscripts.test,constr:t=>new l.CaseMultiscripts(t)},{test:a.CaseLine.test,constr:t=>new a.CaseLine(t)},{test:n.CaseBinomial.test,constr:t=>new n.CaseBinomial(t)},{test:c.CaseProof.test,constr:t=>new c.CaseProof(t)},{test:u.CaseTable.test,constr:t=>new u.CaseTable(t)},{test:h.CaseText.test,constr:t=>new h.CaseText(t)})},5452:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.printNodeList__=e.collapsePunctuated=e.formattedOutput_=e.formattedOutput=e.getInnerNode=e.setOperatorAttribute_=e.createInvisibleOperator_=e.rewriteMfenced=e.cloneContentNode=e.addCollapsedAttribute=e.parentNode_=e.isIgnorable_=e.unitChild_=e.descendNode_=e.ascendNewNode=e.validLca_=e.pathToRoot_=e.attachedElement_=e.prunePath_=e.mathmlLca_=e.lcaType=e.functionApplication_=e.isDescendant_=e.insertNewChild_=e.mergeChildren_=e.collectChildNodes_=e.collateChildNodes_=e.childrenSubset_=e.moveSemanticAttributes_=e.introduceLayerAboveLca=e.introduceNewLayer=e.walkTree=e.enrich=e.SETTINGS=void 0;const n=r(2057),o=r(5740),i=r(5897),s=r(3588),a=r(7516),l=r(5656),c=r(4795),u=r(2298),p=r(3532);function h(t){const e=(0,p.getCase)(t);let r;if(e)return r=e.getMathml(),N(r);if(1===t.mathml.length)return n.Debugger.getInstance().output("Walktree Case 0"),r=t.mathml[0],u.setAttributes(r,t),t.childNodes.length&&(n.Debugger.getInstance().output("Walktree Case 0.1"),t.childNodes.forEach((function(t){"empty"===t.type&&r.appendChild(h(t))}))),N(r);const o=t.contentNodes.map(R);B(t,o);const i=t.childNodes.map(h),s=l.SemanticSkeleton.combineContentChildren(t,o,i);if(r=t.mathmlTree,null===r)n.Debugger.getInstance().output("Walktree Case 1"),r=f(s,t);else{const t=A(s);n.Debugger.getInstance().output("Walktree Case 2"),t?(n.Debugger.getInstance().output("Walktree Case 2.1"),r=t.parentNode):(n.Debugger.getInstance().output("Walktree Case 2.2"),r=D(r))}return r=k(r),v(r,s,t),u.setAttributes(r,t),N(r)}function f(t,e){const r=x(t);let i=r.node;const s=r.type;if(s!==O.VALID||!c.hasEmptyTag(i))if(n.Debugger.getInstance().output("Walktree Case 1.1"),i=o.createElement("mrow"),s===O.PRUNED)n.Debugger.getInstance().output("Walktree Case 1.1.0"),i=d(i,r.node,t);else if(t[0]){n.Debugger.getInstance().output("Walktree Case 1.1.1");const e=A(t),r=y(e.parentNode,t);o.replaceNode(e,i),r.forEach((function(t){i.appendChild(t)}))}return e.mathmlTree||(e.mathmlTree=i),i}function d(t,e,r){let i=w(e);if(c.hasMathTag(i)){n.Debugger.getInstance().output("Walktree Case 1.1.0.0"),m(i,t),o.toArray(i.childNodes).forEach((function(e){t.appendChild(e)}));const e=t;t=i,i=e}const s=r.indexOf(e);return r[s]=i,o.replaceNode(i,t),t.appendChild(i),r.forEach((function(e){t.appendChild(e)})),t}function m(t,e){for(const r of u.EnrichAttributes)t.hasAttribute(r)&&(e.setAttribute(r,t.getAttribute(r)),t.removeAttribute(r))}function y(t,e){const r=o.toArray(t.childNodes);let n=1/0,i=-1/0;return e.forEach((function(t){const e=r.indexOf(t);-1!==e&&(n=Math.min(n,e),i=Math.max(i,e))})),r.slice(n,i+1)}function g(t,e,r){const n=[];let i=o.toArray(t.childNodes),s=!1;for(;i.length;){const t=i.shift();if(t.hasAttribute(u.Attribute.TYPE)){n.push(t);continue}const e=b(t);0!==e.length&&(1!==e.length?(s?t.setAttribute("AuxiliaryImplicit",!0):s=!0,i=e.concat(i)):n.push(t))}const a=[],l=r.childNodes.map((function(t){return t.mathmlTree}));for(;l.length;){const t=l.pop();if(t){if(-1!==n.indexOf(t))break;-1!==e.indexOf(t)&&a.unshift(t)}}return n.concat(a)}function b(t){const e=[];let r=o.toArray(t.childNodes);for(;r.length;){const t=r.shift();t.nodeType===o.NodeType.ELEMENT_NODE&&(t.hasAttribute(u.Attribute.TYPE)?e.push(t):r=o.toArray(t.childNodes).concat(r))}return e}function v(t,e,r){const n="implicit"===r.role&&a.flags.combine_juxtaposition?g(t,e,r):o.toArray(t.childNodes);if(!n.length)return void e.forEach((function(e){t.appendChild(e)}));let i=0;for(;e.length;){const r=e[0];n[i]===r||M(n[i],r)?(e.shift(),i++):n[i]&&-1===e.indexOf(n[i])?i++:(S(r,t)||_(t,n[i],r),e.shift())}}function _(t,e,r){if(!e)return void t.insertBefore(r,null);let n=e,o=P(n);for(;o&&o.firstChild===n&&!n.hasAttribute("AuxiliaryImplicit")&&o!==t;)n=o,o=P(n);o&&(o.insertBefore(r,n),n.removeAttribute("AuxiliaryImplicit"))}function S(t,e){if(!t)return!1;do{if((t=t.parentNode)===e)return!0}while(t);return!1}function M(t,e){const r=s.functionApplication();if(t&&e&&t.textContent&&e.textContent&&t.textContent===r&&e.textContent===r&&"true"===e.getAttribute(u.Attribute.ADDED)){for(let r,n=0;r=t.attributes[n];n++)e.hasAttribute(r.nodeName)||e.setAttribute(r.nodeName,r.nodeValue);return o.replaceNode(t,e),!0}return!1}var O;function x(t){const e=A(t);if(!e)return{type:O.INVALID,node:null};const r=A(t.slice().reverse());if(e===r)return{type:O.VALID,node:e};const n=C(e),o=E(n,t),i=C(r,(function(t){return-1!==o.indexOf(t)})),s=i[0],a=o.indexOf(s);return-1===a?{type:O.INVALID,node:null}:{type:o.length!==n.length?O.PRUNED:T(o[a+1],i[1])?O.VALID:O.INVALID,node:s}}function E(t,e){let r=0;for(;t[r]&&-1===e.indexOf(t[r]);)r++;return t.slice(0,r+1)}function A(t){let e=0,r=null;for(;!r&&e!1),n=[t];for(;!r(t)&&!c.hasMathTag(t)&&t.parentNode;)t=P(t),n.unshift(t);return n}function T(t,e){return!(!t||!e||t.previousSibling||e.nextSibling)}function N(t){for(;!c.hasMathTag(t)&&L(t);)t=P(t);return t}function w(t){const e=o.toArray(t.childNodes);if(!e)return t;const r=e.filter((function(t){return t.nodeType===o.NodeType.ELEMENT_NODE&&!c.hasIgnoreTag(t)}));return 1===r.length&&c.hasEmptyTag(r[0])&&!r[0].hasAttribute(u.Attribute.TYPE)?w(r[0]):t}function L(t){const e=P(t);return!(!e||!c.hasEmptyTag(e))&&o.toArray(e.childNodes).every((function(e){return e===t||I(e)}))}function I(t){if(t.nodeType!==o.NodeType.ELEMENT_NODE)return!0;if(!t||c.hasIgnoreTag(t))return!0;const e=o.toArray(t.childNodes);return!(!c.hasEmptyTag(t)&&e.length||c.hasDisplayTag(t)||t.hasAttribute(u.Attribute.TYPE)||c.isOrphanedGlyph(t))&&o.toArray(t.childNodes).every(I)}function P(t){return t.parentNode}function R(t){if(t.mathml.length)return h(t);const r=e.SETTINGS.implicit?j(t):o.createElement("mrow");return t.mathml=[r],r}function k(t){if("MFENCED"!==o.tagName(t))return t;const e=o.createElement("mrow");for(let r,n=0;r=t.attributes[n];n++)-1===["open","close","separators"].indexOf(r.name)&&e.setAttribute(r.name,r.value);return o.toArray(t.childNodes).forEach((function(t){e.appendChild(t)})),o.replaceNode(t,e),e}function j(t){const e=o.createElement("mo"),r=o.createTextNode(t.textContent);return e.appendChild(r),u.setAttributes(e,t),e.setAttribute(u.Attribute.ADDED,"true"),e}function B(t,e){const r=t.type+(t.textContent?","+t.textContent:"");e.forEach((function(t){D(t).setAttribute(u.Attribute.OPERATOR,r)}))}function D(t){const e=o.toArray(t.childNodes);if(!e)return t;const r=e.filter((function(t){return!I(t)})),n=[];for(let t,e=0;t=r[e];e++)if(c.hasEmptyTag(t)){const e=D(t);e&&e!==t&&n.push(e)}else n.push(t);return 1===n.length?n[0]:t}function F(t,e,r,n){const o=n||!1;H(t,"Original MathML",o),H(r,"Semantic Tree",o),H(e,"Semantically enriched MathML",o)}function H(t,e,r){const n=o.formatXml(t.toString());r?console.info(e+":\n```html\n"+u.removeAttributePrefix(n)+"\n```\n"):console.info(n)}e.SETTINGS={collapsed:!0,implicit:!0},e.enrich=function(t,e){const r=o.cloneNode(t);return h(e.root),i.default.getInstance().structure&&t.setAttribute(u.Attribute.STRUCTURE,l.SemanticSkeleton.fromStructure(t,e).toString()),n.Debugger.getInstance().generateOutput((function(){return F(r,t,e,!0),[]})),t},e.walkTree=h,e.introduceNewLayer=f,e.introduceLayerAboveLca=d,e.moveSemanticAttributes_=m,e.childrenSubset_=y,e.collateChildNodes_=g,e.collectChildNodes_=b,e.mergeChildren_=v,e.insertNewChild_=_,e.isDescendant_=S,e.functionApplication_=M,function(t){t.VALID="valid",t.INVALID="invalid",t.PRUNED="pruned"}(O=e.lcaType||(e.lcaType={})),e.mathmlLca_=x,e.prunePath_=E,e.attachedElement_=A,e.pathToRoot_=C,e.validLca_=T,e.ascendNewNode=N,e.descendNode_=w,e.unitChild_=L,e.isIgnorable_=I,e.parentNode_=P,e.addCollapsedAttribute=function(t,e){const r=new l.SemanticSkeleton(e);t.setAttribute(u.Attribute.COLLAPSED,r.toString())},e.cloneContentNode=R,e.rewriteMfenced=k,e.createInvisibleOperator_=j,e.setOperatorAttribute_=B,e.getInnerNode=D,e.formattedOutput=F,e.formattedOutput_=H,e.collapsePunctuated=function(t,e){const r=!!e,n=e||[],o=t.parent,i=t.contentNodes.map((function(t){return t.id}));i.unshift("c");const s=[t.id,i];for(let e,i=0;e=t.childNodes[i];i++){const t=h(e);n.push(t);const i=D(t);o&&!r&&i.setAttribute(u.Attribute.PARENT,o.id.toString()),s.push(e.id)}return s},e.printNodeList__=function(t,e){console.info(t),o.toArray(e).forEach((function(t){console.info(t.toString())})),console.info("<<<<<<<<<<<<<<<<<")}},5105:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractHighlighter=void 0;const n=r(5274),o=r(2298);class i{constructor(){this.color=null,this.mactionName="",this.currentHighlights=[]}highlight(t){this.currentHighlights.push(t.map((t=>{const e=this.highlightNode(t);return this.setHighlighted(t),e})))}highlightAll(t){const e=this.getMactionNodes(t);for(let t,r=0;t=e[r];r++)this.highlight([t])}unhighlight(){const t=this.currentHighlights.pop();t&&t.forEach((t=>{this.isHighlighted(t.node)&&(this.unhighlightNode(t),this.unsetHighlighted(t.node))}))}unhighlightAll(){for(;this.currentHighlights.length>0;)this.unhighlight()}setColor(t){this.color=t}colorString(){return this.color.rgba()}addEvents(t,e){const r=this.getMactionNodes(t);for(let t,n=0;t=r[n];n++)for(const r in e)t.addEventListener(r,e[r])}getMactionNodes(t){return Array.from(t.getElementsByClassName(this.mactionName))}isMactionNode(t){const e=t.className||t.getAttribute("class");return!!e&&!!e.match(new RegExp(this.mactionName))}isHighlighted(t){return t.hasAttribute(i.ATTR)}setHighlighted(t){t.setAttribute(i.ATTR,"true")}unsetHighlighted(t){t.removeAttribute(i.ATTR)}colorizeAll(t){n.evalXPath(`.//*[@${o.Attribute.ID}]`,t).forEach((t=>this.colorize(t)))}uncolorizeAll(t){n.evalXPath(`.//*[@${o.Attribute.ID}]`,t).forEach((t=>this.uncolorize(t)))}colorize(t){const e=(0,o.addPrefix)("foreground");t.hasAttribute(e)&&(t.setAttribute(e+"-old",t.style.color),t.style.color=t.getAttribute(e))}uncolorize(t){const e=(0,o.addPrefix)("foreground")+"-old";t.hasAttribute(e)&&(t.style.color=t.getAttribute(e))}}e.AbstractHighlighter=i,i.ATTR="sre-highlight"},6937:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.ChtmlHighlighter=void 0;const n=r(933);class o extends n.CssHighlighter{constructor(){super()}isMactionNode(t){return t.tagName.toUpperCase()===this.mactionName.toUpperCase()}getMactionNodes(t){return Array.from(t.getElementsByTagName(this.mactionName))}}e.ChtmlHighlighter=o},8396:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ContrastPicker=e.ColorPicker=void 0;const r={red:{red:255,green:0,blue:0},green:{red:0,green:255,blue:0},blue:{red:0,green:0,blue:255},yellow:{red:255,green:255,blue:0},cyan:{red:0,green:255,blue:255},magenta:{red:255,green:0,blue:255},white:{red:255,green:255,blue:255},black:{red:0,green:0,blue:0}};function n(t,e){const n=t||{color:e};let o=Object.prototype.hasOwnProperty.call(n,"color")?r[n.color]:n;return o||(o=r[e]),o.alpha=Object.prototype.hasOwnProperty.call(n,"alpha")?n.alpha:1,function(t){const e=t=>(t=Math.max(t,0),t=Math.min(255,t),Math.round(t));return t.red=e(t.red),t.green=e(t.green),t.blue=e(t.blue),t.alpha=Math.max(t.alpha,0),t.alpha=Math.min(1,t.alpha),t}(o)}class o{constructor(t,e){this.foreground=n(e,o.DEFAULT_FOREGROUND_),this.background=n(t,o.DEFAULT_BACKGROUND_)}static toHex(t){const e=t.toString(16);return 1===e.length?"0"+e:e}rgba(){const t=function(t){return"rgba("+t.red+","+t.green+","+t.blue+","+t.alpha+")"};return{background:t(this.background),foreground:t(this.foreground)}}rgb(){const t=function(t){return"rgb("+t.red+","+t.green+","+t.blue+")"};return{background:t(this.background),alphaback:this.background.alpha.toString(),foreground:t(this.foreground),alphafore:this.foreground.alpha.toString()}}hex(){const t=function(t){return"#"+o.toHex(t.red)+o.toHex(t.green)+o.toHex(t.blue)};return{background:t(this.background),alphaback:this.background.alpha.toString(),foreground:t(this.foreground),alphafore:this.foreground.alpha.toString()}}}e.ColorPicker=o,o.DEFAULT_BACKGROUND_="blue",o.DEFAULT_FOREGROUND_="black";e.ContrastPicker=class{constructor(){this.hue=10,this.sat=100,this.light=50,this.incr=50}generate(){return e=function(t,e,r){e=e>1?e/100:e,r=r>1?r/100:r;const n=(1-Math.abs(2*r-1))*e,o=n*(1-Math.abs(t/60%2-1)),i=r-n/2;let s=0,a=0,l=0;return 0<=t&&t<60?[s,a,l]=[n,o,0]:60<=t&&t<120?[s,a,l]=[o,n,0]:120<=t&&t<180?[s,a,l]=[0,n,o]:180<=t&&t<240?[s,a,l]=[0,o,n]:240<=t&&t<300?[s,a,l]=[o,0,n]:300<=t&&t<360&&([s,a,l]=[n,0,o]),{red:s+i,green:a+i,blue:l+i}}(this.hue,this.sat,this.light),"rgb("+(t={red:Math.round(255*e.red),green:Math.round(255*e.green),blue:Math.round(255*e.blue)}).red+","+t.green+","+t.blue+")";var t,e}increment(){this.hue=(this.hue+this.incr)%360}}},933:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.CssHighlighter=void 0;const n=r(5105);class o extends n.AbstractHighlighter{constructor(){super(),this.mactionName="mjx-maction"}highlightNode(t){const e={node:t,background:t.style.backgroundColor,foreground:t.style.color},r=this.colorString();return t.style.backgroundColor=r.background,t.style.color=r.foreground,e}unhighlightNode(t){t.node.style.backgroundColor=t.background,t.node.style.color=t.foreground}}e.CssHighlighter=o},3090:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.highlighterMapping_=e.addEvents=e.highlighter=void 0;const n=r(6937),o=r(8396),i=r(933),s=r(2598),a=r(4500),l=r(7071),c=r(4346),u=r(2222);e.highlighter=function(t,r,n){const i=new o.ColorPicker(t,r),s="NativeMML"===n.renderer&&"Safari"===n.browser?"MML-CSS":"SVG"===n.renderer&&"v3"===n.browser?"SVG-V3":n.renderer,a=new(e.highlighterMapping_[s]||e.highlighterMapping_.NativeMML);return a.setColor(i),a},e.addEvents=function(t,r,n){const o=e.highlighterMapping_[n.renderer];o&&(new o).addEvents(t,r)},e.highlighterMapping_={SVG:c.SvgHighlighter,"SVG-V3":u.SvgV3Highlighter,NativeMML:l.MmlHighlighter,"HTML-CSS":s.HtmlHighlighter,"MML-CSS":a.MmlCssHighlighter,CommonHTML:i.CssHighlighter,CHTML:n.ChtmlHighlighter}},2598:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.HtmlHighlighter=void 0;const n=r(5740),o=r(5105);class i extends o.AbstractHighlighter{constructor(){super(),this.mactionName="maction"}highlightNode(t){const e={node:t,foreground:t.style.color,position:t.style.position},r=this.color.rgb();t.style.color=r.foreground,t.style.position="relative";const o=t.bbox;if(o&&o.w){const i=.05,s=0,a=n.createElement("span"),l=parseFloat(t.style.paddingLeft||"0");a.style.backgroundColor=r.background,a.style.opacity=r.alphaback.toString(),a.style.display="inline-block",a.style.height=o.h+o.d+2*i+"em",a.style.verticalAlign=-o.d+"em",a.style.marginTop=a.style.marginBottom=-i+"em",a.style.width=o.w+2*s+"em",a.style.marginLeft=l-s+"em",a.style.marginRight=-o.w-s-l+"em",t.parentNode.insertBefore(a,t),e.box=a}return e}unhighlightNode(t){const e=t.node;e.style.color=t.foreground,e.style.position=t.position,t.box&&t.box.parentNode.removeChild(t.box)}}e.HtmlHighlighter=i},4500:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.MmlCssHighlighter=void 0;const n=r(933);class o extends n.CssHighlighter{constructor(){super(),this.mactionName="maction"}getMactionNodes(t){return Array.from(t.getElementsByTagName(this.mactionName))}isMactionNode(t){return t.tagName===this.mactionName}}e.MmlCssHighlighter=o},7071:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.MmlHighlighter=void 0;const n=r(5105);class o extends n.AbstractHighlighter{constructor(){super(),this.mactionName="maction"}highlightNode(t){let e=t.getAttribute("style");return e+=";background-color: "+this.colorString().background,e+=";color: "+this.colorString().foreground,t.setAttribute("style",e),{node:t}}unhighlightNode(t){let e=t.node.getAttribute("style");e=e.replace(";background-color: "+this.colorString().background,""),e=e.replace(";color: "+this.colorString().foreground,""),t.node.setAttribute("style",e)}colorString(){return this.color.rgba()}getMactionNodes(t){return Array.from(t.getElementsByTagName(this.mactionName))}isMactionNode(t){return t.tagName===this.mactionName}}e.MmlHighlighter=o},4346:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SvgHighlighter=void 0;const n=r(5740),o=r(5105);class i extends o.AbstractHighlighter{constructor(){super(),this.mactionName="mjx-svg-maction"}highlightNode(t){let e;if(this.isHighlighted(t))return e={node:t.previousSibling||t,background:t.style.backgroundColor,foreground:t.style.color},e;if("svg"===t.tagName){const e={node:t,background:t.style.backgroundColor,foreground:t.style.color};return t.style.backgroundColor=this.colorString().background,t.style.color=this.colorString().foreground,e}const r=n.createElementNS("http://www.w3.org/2000/svg","rect");let i;if("use"===t.nodeName){const e=n.createElementNS("http://www.w3.org/2000/svg","g");t.parentNode.insertBefore(e,t),e.appendChild(t),i=e.getBBox(),e.parentNode.replaceChild(t,e)}else i=t.getBBox();r.setAttribute("x",(i.x-40).toString()),r.setAttribute("y",(i.y-40).toString()),r.setAttribute("width",(i.width+80).toString()),r.setAttribute("height",(i.height+80).toString());const s=t.getAttribute("transform");return s&&r.setAttribute("transform",s),r.setAttribute("fill",this.colorString().background),r.setAttribute(o.AbstractHighlighter.ATTR,"true"),t.parentNode.insertBefore(r,t),e={node:r,foreground:t.getAttribute("fill")},t.setAttribute("fill",this.colorString().foreground),e}setHighlighted(t){"svg"===t.tagName&&super.setHighlighted(t)}unhighlightNode(t){if("background"in t)return t.node.style.backgroundColor=t.background,void(t.node.style.color=t.foreground);t.foreground?t.node.nextSibling.setAttribute("fill",t.foreground):t.node.nextSibling.removeAttribute("fill"),t.node.parentNode.removeChild(t.node)}isMactionNode(t){let e=t.className||t.getAttribute("class");return e=void 0!==e.baseVal?e.baseVal:e,!!e&&!!e.match(new RegExp(this.mactionName))}}e.SvgHighlighter=i},2222:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SvgV3Highlighter=void 0;const n=r(5740),o=r(5274),i=r(5105),s=r(8396),a=r(4346);class l extends a.SvgHighlighter{constructor(){super(),this.mactionName="maction"}highlightNode(t){let e;if(this.isHighlighted(t))return e={node:t,background:this.colorString().background,foreground:this.colorString().foreground},e;if("svg"===t.tagName||"MJX-CONTAINER"===t.tagName)return e={node:t,background:t.style.backgroundColor,foreground:t.style.color},t.style.backgroundColor=this.colorString().background,t.style.color=this.colorString().foreground,e;const r=n.createElementNS("http://www.w3.org/2000/svg","rect");r.setAttribute("sre-highlighter-added","true");const o=t.getBBox();r.setAttribute("x",(o.x-40).toString()),r.setAttribute("y",(o.y-40).toString()),r.setAttribute("width",(o.width+80).toString()),r.setAttribute("height",(o.height+80).toString());const a=t.getAttribute("transform");if(a&&r.setAttribute("transform",a),r.setAttribute("fill",this.colorString().background),t.setAttribute(i.AbstractHighlighter.ATTR,"true"),t.parentNode.insertBefore(r,t),e={node:t,foreground:t.getAttribute("fill")},"rect"===t.nodeName){const e=new s.ColorPicker({alpha:0,color:"black"});t.setAttribute("fill",e.rgba().foreground)}else t.setAttribute("fill",this.colorString().foreground);return e}unhighlightNode(t){const e=t.node.previousSibling;if(e&&e.hasAttribute("sre-highlighter-added"))return t.foreground?t.node.setAttribute("fill",t.foreground):t.node.removeAttribute("fill"),void t.node.parentNode.removeChild(e);t.node.style.backgroundColor=t.background,t.node.style.color=t.foreground}isMactionNode(t){return t.getAttribute("data-mml-node")===this.mactionName}getMactionNodes(t){return Array.from(o.evalXPath(`.//*[@data-mml-node="${this.mactionName}"]`,t))}}e.SvgV3Highlighter=l},7222:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.StaticTrieNode=e.AbstractTrieNode=void 0;const n=r(2057),o=r(4391);class i{constructor(t,e){this.constraint=t,this.test=e,this.children_={},this.kind=o.TrieNodeKind.ROOT}getConstraint(){return this.constraint}getKind(){return this.kind}applyTest(t){return this.test(t)}addChild(t){const e=t.getConstraint(),r=this.children_[e];return this.children_[e]=t,r}getChild(t){return this.children_[t]}getChildren(){const t=[];for(const e in this.children_)t.push(this.children_[e]);return t}findChildren(t){const e=[];for(const r in this.children_){const n=this.children_[r];n.applyTest(t)&&e.push(n)}return e}removeChild(t){delete this.children_[t]}toString(){return this.constraint}}e.AbstractTrieNode=i;e.StaticTrieNode=class extends i{constructor(t,e){super(t,e),this.rule_=null,this.kind=o.TrieNodeKind.STATIC}getRule(){return this.rule_}setRule(t){this.rule_&&n.Debugger.getInstance().output("Replacing rule "+this.rule_+" with "+t),this.rule_=t}toString(){return this.getRule()?this.constraint+"\n==> "+this.getRule().action:this.constraint}}},4508:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.Trie=void 0;const n=r(4391),o=r(9701);class i{constructor(){this.root=(0,o.getNode)(n.TrieNodeKind.ROOT,"",null)}static collectRules_(t){const e=[];let r=[t];for(;r.length;){const t=r.shift();if(t.getKind()===n.TrieNodeKind.QUERY||t.getKind()===n.TrieNodeKind.BOOLEAN){const r=t.getRule();r&&e.unshift(r)}r=r.concat(t.getChildren())}return e}static printWithDepth_(t,e,r){r+=new Array(e+2).join(e.toString())+": "+t.toString()+"\n";const n=t.getChildren();for(let t,o=0;t=n[o];o++)r=i.printWithDepth_(t,e+1,r);return r}static order_(t){const e=t.getChildren();if(!e.length)return 0;const r=Math.max.apply(null,e.map(i.order_));return Math.max(e.length,r)}addRule(t){let e=this.root;const r=t.context,o=t.dynamicCstr.getValues();for(let t=0,i=o.length;t{e.getKind()===n.TrieNodeKind.DYNAMIC&&-1===t.indexOf(e.getConstraint())||o.push(e)}))}r=o.slice()}for(;r.length;){const e=r.shift();if(e.getRule){const t=e.getRule();t&&o.push(t)}const n=e.findChildren(t);r=r.concat(n)}return o}hasSubtrie(t){let e=this.root;for(let r=0,n=t.length;r!0)),this.kind=c.TrieNodeKind.ROOT}}e.RootTrieNode=u;class p extends a.AbstractTrieNode{constructor(t){super(t,(e=>e===t)),this.kind=c.TrieNodeKind.DYNAMIC}}e.DynamicTrieNode=p;const h={"=":(t,e)=>t===e,"!=":(t,e)=>t!==e,"<":(t,e)=>t":(t,e)=>t>e,"<=":(t,e)=>t<=e,">=":(t,e)=>t>=e};function f(t){if(t.match(/^self::\*$/))return t=>!0;if(t.match(/^self::\w+$/)){const e=t.slice(6).toUpperCase();return t=>t.tagName&&n.tagName(t)===e}if(t.match(/^self::\w+:\w+$/)){const e=t.split(":"),r=o.resolveNameSpace(e[2]);if(!r)return null;const n=e[3].toUpperCase();return t=>t.localName&&t.localName.toUpperCase()===n&&t.namespaceURI===r}if(t.match(/^@\w+$/)){const e=t.slice(1);return t=>t.hasAttribute&&t.hasAttribute(e)}if(t.match(/^@\w+="[\w\d ]+"$/)){const e=t.split("="),r=e[0].slice(1),n=e[1].slice(1,-1);return t=>t.hasAttribute&&t.hasAttribute(r)&&t.getAttribute(r)===n}if(t.match(/^@\w+!="[\w\d ]+"$/)){const e=t.split("!="),r=e[0].slice(1),n=e[1].slice(1,-1);return t=>!t.hasAttribute||!t.hasAttribute(r)||t.getAttribute(r)!==n}if(t.match(/^contains\(\s*@grammar\s*,\s*"[\w\d ]+"\s*\)$/)){const e=t.split('"')[1];return t=>!!i.Grammar.getInstance().getParameter(e)}if(t.match(/^not\(\s*contains\(\s*@grammar\s*,\s*"[\w\d ]+"\s*\)\s*\)$/)){const e=t.split('"')[1];return t=>!i.Grammar.getInstance().getParameter(e)}if(t.match(/^name\(\.\.\/\.\.\)="\w+"$/)){const e=t.split('"')[1].toUpperCase();return t=>{var r,o;return(null===(o=null===(r=t.parentNode)||void 0===r?void 0:r.parentNode)||void 0===o?void 0:o.tagName)&&n.tagName(t.parentNode.parentNode)===e}}if(t.match(/^count\(preceding-sibling::\*\)=\d+$/)){const e=t.split("="),r=parseInt(e[1],10);return t=>{var e;return(null===(e=t.parentNode)||void 0===e?void 0:e.childNodes[r])===t}}if(t.match(/^.+\[@category!?=".+"\]$/)){let[,e,r,n]=t.match(/^(.+)\[@category(!?=)"(.+)"\]$/);const i=n.match(/^unit:(.+)$/);let a="";return i&&(n=i[1],a=":unit"),t=>{const i=o.evalXPath(e,t)[0];if(i){const t=s.lookupCategory(i.textContent+a);return"="===r?t===n:t!==n}return!1}}if(t.match(/^string-length\(.+\)\W+\d+/)){const[,e,r,n]=t.match(/^string-length\((.+)\)(\W+)(\d+)/),i=h[r]||h["="],s=parseInt(n,10);return t=>{const r=o.evalXPath(e,t)[0];return!!r&&i(Array.from(r.textContent).length,s)}}return null}e.constraintTest_=f;class d extends l.StaticTrieNode{constructor(t,e){super(t,f(t)),this.context=e,this.kind=c.TrieNodeKind.QUERY}applyTest(t){return this.test?this.test(t):this.context.applyQuery(t,this.constraint)===t}}e.QueryTrieNode=d;class m extends l.StaticTrieNode{constructor(t,e){super(t,f(t)),this.context=e,this.kind=c.TrieNodeKind.BOOLEAN}applyTest(t){return this.test?this.test(t):this.context.applyConstraint(t,this.constraint)}}e.BooleanTrieNode=m},7491:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.completeLocale=e.getLocale=e.setLocale=e.locales=void 0;const n=r(5897),o=r(1377),i=r(2105),s=r(4249),a=r(8657),l=r(173),c=r(9393),u=r(7978),p=r(5540),h=r(5218),f=r(3887),d=r(8384),m=r(7206),y=r(7734),g=r(7264),b=r(4356);function v(){const t=o.Variables.ensureLocale(n.default.getInstance().locale,n.default.getInstance().defaultLocale);return n.default.getInstance().locale=t,e.locales[t]()}e.locales={ca:s.ca,da:a.da,de:l.de,en:c.en,es:u.es,fr:p.fr,hi:h.hi,it:f.it,nb:d.nb,nn:y.nn,sv:g.sv,nemeth:m.nemeth},e.setLocale=function(){const t=v();if(function(t){const e=n.default.getInstance().subiso;-1===t.SUBISO.all.indexOf(e)&&(n.default.getInstance().subiso=t.SUBISO.default);t.SUBISO.current=n.default.getInstance().subiso}(t),t){for(const e of Object.getOwnPropertyNames(t))b.LOCALE[e]=t[e];for(const[e,r]of Object.entries(t.CORRECTIONS))i.Grammar.getInstance().setCorrection(e,r)}},e.getLocale=v,e.completeLocale=function(t){const r=e.locales[t.locale];if(!r)return void console.error("Locale "+t.locale+" does not exist!");const n=t.kind.toUpperCase(),o=t.messages;if(!o)return;const i=r();for(const[t,e]of Object.entries(o))i[n][t]=e}},4356:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.createLocale=e.LOCALE=void 0;const n=r(7549);function o(){return{FUNCTIONS:(0,n.FUNCTIONS)(),MESSAGES:(0,n.MESSAGES)(),ALPHABETS:(0,n.ALPHABETS)(),NUMBERS:(0,n.NUMBERS)(),COMBINERS:{},CORRECTIONS:{},SUBISO:(0,n.SUBISO)()}}e.LOCALE=o(),e.createLocale=o},2536:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.localeFontCombiner=e.extractString=e.localEnclose=e.localRole=e.localFont=e.combinePostfixIndex=e.nestingToString=void 0;const n=r(4356),o=r(4977);function i(t,e){return void 0===t?e:"string"==typeof t?t:t[0]}e.nestingToString=function(t){switch(t){case 1:return n.LOCALE.MESSAGES.MS.ONCE||"";case 2:return n.LOCALE.MESSAGES.MS.TWICE;default:return t.toString()}},e.combinePostfixIndex=function(t,e){return t===n.LOCALE.MESSAGES.MS.ROOTINDEX||t===n.LOCALE.MESSAGES.MS.INDEX?t:t+" "+e},e.localFont=function(t){return i(n.LOCALE.MESSAGES.font[t],t)},e.localRole=function(t){return i(n.LOCALE.MESSAGES.role[t],t)},e.localEnclose=function(t){return i(n.LOCALE.MESSAGES.enclose[t],t)},e.extractString=i,e.localeFontCombiner=function(t){return"string"==typeof t?{font:t,combiner:n.LOCALE.ALPHABETS.combiner}:{font:t[0],combiner:n.LOCALE.COMBINERS[t[1]]||o.Combiners[t[1]]||n.LOCALE.ALPHABETS.combiner}}},4249:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.ca=void 0;const n=r(4356),o=r(2536),i=r(614),s=r(4977),a=function(t,e,r){return t="sans serif "+(r?r+" "+t:t),e?t+" "+e:t};let l=null;e.ca=function(){return l||(l=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.COMBINERS.sansserif=a,t.FUNCTIONS.fracNestDepth=t=>!1,t.FUNCTIONS.combineRootIndex=o.combinePostfixIndex,t.FUNCTIONS.combineNestedRadical=(t,e,r)=>t+r,t.FUNCTIONS.fontRegexp=t=>RegExp("^"+t+" "),t.FUNCTIONS.plural=t=>/.*os$/.test(t)?t+"sos":/.*s$/.test(t)?t+"os":/.*ga$/.test(t)?t.slice(0,-2)+"gues":/.*\xe7a$/.test(t)?t.slice(0,-2)+"ces":/.*ca$/.test(t)?t.slice(0,-2)+"ques":/.*ja$/.test(t)?t.slice(0,-2)+"ges":/.*qua$/.test(t)?t.slice(0,-3)+"q\xfces":/.*a$/.test(t)?t.slice(0,-1)+"es":/.*(e|i)$/.test(t)?t+"ns":/.*\xed$/.test(t)?t.slice(0,-1)+"ins":t+"s",t.FUNCTIONS.si=(t,e)=>(e.match(/^metre/)&&(t=t.replace(/a$/,"\xe0").replace(/o$/,"\xf2").replace(/i$/,"\xed")),t+e),t.ALPHABETS.combiner=s.Combiners.prefixCombiner,t}()),l}},8657:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.da=void 0;const n=r(4356),o=r(2536),i=r(3866),s=r(4977);let a=null;e.da=function(){return a||(a=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.FUNCTIONS.radicalNestDepth=o.nestingToString,t.FUNCTIONS.fontRegexp=e=>e===t.ALPHABETS.capPrefix.default?RegExp("^"+e+" "):RegExp(" "+e+"$"),t.ALPHABETS.combiner=s.Combiners.postfixCombiner,t.ALPHABETS.digitTrans.default=i.default.numberToWords,t}()),a}},173:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.de=void 0;const n=r(2105),o=r(2536),i=r(4356),s=r(1435),a=function(t,e,r){return"s"===r&&(e=e.split(" ").map((function(t){return t.replace(/s$/,"")})).join(" "),r=""),t=r?r+" "+t:t,e?e+" "+t:t},l=function(t,e,r){return t=r&&"s"!==r?r+" "+t:t,e?t+" "+e:t};let c=null;e.de=function(){return c||(c=function(){const t=(0,i.createLocale)();return t.NUMBERS=s.default,t.COMBINERS.germanPostfix=l,t.ALPHABETS.combiner=a,t.FUNCTIONS.radicalNestDepth=e=>e>1?t.NUMBERS.numberToWords(e)+"fach":"",t.FUNCTIONS.combineRootIndex=(t,e)=>{const r=e?e+"wurzel":"";return t.replace("Wurzel",r)},t.FUNCTIONS.combineNestedRadical=(t,e,r)=>{const n=(e?e+" ":"")+(t=r.match(/exponent$/)?t+"r":t);return r.match(/ /)?r.replace(/ /," "+n+" "):n+" "+r},t.FUNCTIONS.fontRegexp=function(t){return t=t.split(" ").map((function(t){return t.replace(/s$/,"(|s)")})).join(" "),new RegExp("((^"+t+" )|( "+t+"$))")},t.CORRECTIONS.correctOne=t=>t.replace(/^eins$/,"ein"),t.CORRECTIONS.localFontNumber=t=>(0,o.localFont)(t).split(" ").map((function(t){return t.replace(/s$/,"")})).join(" "),t.CORRECTIONS.lowercase=t=>t.toLowerCase(),t.CORRECTIONS.article=t=>{const e=n.Grammar.getInstance().getParameter("case"),r=n.Grammar.getInstance().getParameter("plural");return"dative"===e?{der:"dem",die:r?"den":"der",das:"dem"}[t]:t},t.CORRECTIONS.masculine=t=>"dative"===n.Grammar.getInstance().getParameter("case")?t+"n":t,t}()),c}},9393:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.en=void 0;const n=r(2105),o=r(4356),i=r(2536),s=r(310),a=r(4977);let l=null;e.en=function(){return l||(l=function(){const t=(0,o.createLocale)();return t.NUMBERS=s.default,t.FUNCTIONS.radicalNestDepth=i.nestingToString,t.FUNCTIONS.plural=t=>/.*s$/.test(t)?t:t+"s",t.ALPHABETS.combiner=a.Combiners.prefixCombiner,t.ALPHABETS.digitTrans.default=s.default.numberToWords,t.CORRECTIONS.article=t=>n.Grammar.getInstance().getParameter("noArticle")?"":t,t}()),l}},7978:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.es=void 0;const n=r(4356),o=r(2536),i=r(4634),s=r(4977),a=function(t,e,r){return t="sans serif "+(r?r+" "+t:t),e?t+" "+e:t};let l=null;e.es=function(){return l||(l=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.COMBINERS.sansserif=a,t.FUNCTIONS.fracNestDepth=t=>!1,t.FUNCTIONS.combineRootIndex=o.combinePostfixIndex,t.FUNCTIONS.combineNestedRadical=(t,e,r)=>t+r,t.FUNCTIONS.fontRegexp=t=>RegExp("^"+t+" "),t.FUNCTIONS.plural=t=>/.*(a|e|i|o|u)$/.test(t)?t+"s":/.*z$/.test(t)?t.slice(0,-1)+"ces":/.*c$/.test(t)?t.slice(0,-1)+"ques":/.*g$/.test(t)?t+"ues":/.*\u00f3n$/.test(t)?t.slice(0,-2)+"ones":t+"es",t.FUNCTIONS.si=(t,e)=>(e.match(/^metro/)&&(t=t.replace(/a$/,"\xe1").replace(/o$/,"\xf3").replace(/i$/,"\xed")),t+e),t.ALPHABETS.combiner=s.Combiners.prefixCombiner,t}()),l}},5540:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.fr=void 0;const n=r(2105),o=r(4356),i=r(2536),s=r(2350),a=r(4977);let l=null;e.fr=function(){return l||(l=function(){const t=(0,o.createLocale)();return t.NUMBERS=s.default,t.FUNCTIONS.radicalNestDepth=i.nestingToString,t.FUNCTIONS.combineRootIndex=i.combinePostfixIndex,t.FUNCTIONS.combineNestedFraction=(t,e,r)=>r.replace(/ $/g,"")+e+t,t.FUNCTIONS.combineNestedRadical=(t,e,r)=>r+" "+t,t.FUNCTIONS.fontRegexp=t=>RegExp(" (en |)"+t+"$"),t.FUNCTIONS.plural=t=>/.*s$/.test(t)?t:t+"s",t.CORRECTIONS.article=t=>n.Grammar.getInstance().getParameter("noArticle")?"":t,t.ALPHABETS.combiner=a.Combiners.romanceCombiner,t.SUBISO={default:"fr",current:"fr",all:["fr","be","ch"]},t}()),l}},5218:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.hi=void 0;const n=r(4356),o=r(4438),i=r(4977),s=r(2536);let a=null;e.hi=function(){return a||(a=function(){const t=(0,n.createLocale)();return t.NUMBERS=o.default,t.ALPHABETS.combiner=i.Combiners.prefixCombiner,t.FUNCTIONS.radicalNestDepth=s.nestingToString,t}()),a}},3887:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.it=void 0;const n=r(2536),o=r(4356),i=r(8825),s=r(4977),a=function(t,e,r){return t.match(/^[a-zA-Z]$/)&&(e=e.replace("cerchiato","cerchiata")),t=r?t+" "+r:t,e?t+" "+e:t};let l=null;e.it=function(){return l||(l=function(){const t=(0,o.createLocale)();return t.NUMBERS=i.default,t.COMBINERS.italianPostfix=a,t.FUNCTIONS.radicalNestDepth=n.nestingToString,t.FUNCTIONS.combineRootIndex=n.combinePostfixIndex,t.FUNCTIONS.combineNestedFraction=(t,e,r)=>r.replace(/ $/g,"")+e+t,t.FUNCTIONS.combineNestedRadical=(t,e,r)=>r+" "+t,t.FUNCTIONS.fontRegexp=t=>RegExp(" (en |)"+t+"$"),t.ALPHABETS.combiner=s.Combiners.romanceCombiner,t}()),l}},8384:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.nb=void 0;const n=r(4356),o=r(2536),i=r(8274),s=r(4977);let a=null;e.nb=function(){return a||(a=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.ALPHABETS.combiner=s.Combiners.prefixCombiner,t.ALPHABETS.digitTrans.default=i.default.numberToWords,t.FUNCTIONS.radicalNestDepth=o.nestingToString,t}()),a}},7206:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.nemeth=void 0;const n=r(4356),o=r(3720),i=r(4977),s=function(t){return t.match(RegExp("^"+h.ALPHABETS.languagePrefix.english))?t.slice(1):t},a=function(t,e,r){return t=s(t),e?t+e:t},l=function(t,e,r){return e+s(t)},c=function(t,e,r){return e+(r||"")+(t=s(t))+"\u283b"},u=function(t,e,r){return e+(r||"")+(t=s(t))+"\u283b\u283b"},p=function(t,e,r){return e+(t=s(t))+"\u283e"};let h=null;e.nemeth=function(){return h||(h=function(){const t=(0,n.createLocale)();return t.NUMBERS=o.default,t.COMBINERS={postfixCombiner:a,germanCombiner:l,embellishCombiner:c,doubleEmbellishCombiner:u,parensCombiner:p},t.FUNCTIONS.fracNestDepth=t=>!1,t.FUNCTIONS.fontRegexp=t=>RegExp("^"+t),t.FUNCTIONS.si=i.identityTransformer,t.ALPHABETS.combiner=(t,e,r)=>e?e+r+t:s(t),t.ALPHABETS.digitTrans={default:o.default.numberToWords},t}()),h}},7734:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.nn=void 0;const n=r(4356),o=r(2536),i=r(8274),s=r(4977);let a=null;e.nn=function(){return a||(a=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.ALPHABETS.combiner=s.Combiners.prefixCombiner,t.ALPHABETS.digitTrans.default=i.default.numberToWords,t.FUNCTIONS.radicalNestDepth=o.nestingToString,t.SUBISO={default:"",current:"",all:["","alt"]},t}()),a}},7264:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.sv=void 0;const n=r(4356),o=r(2536),i=r(3898),s=r(4977);let a=null;e.sv=function(){return a||(a=function(){const t=(0,n.createLocale)();return t.NUMBERS=i.default,t.FUNCTIONS.radicalNestDepth=o.nestingToString,t.FUNCTIONS.fontRegexp=function(t){return new RegExp("((^"+t+" )|( "+t+"$))")},t.ALPHABETS.combiner=s.Combiners.prefixCombiner,t.ALPHABETS.digitTrans.default=i.default.numberToWords,t.CORRECTIONS.correctOne=t=>t.replace(/^ett$/,"en"),t}()),a}},7549:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SUBISO=e.FUNCTIONS=e.ALPHABETS=e.NUMBERS=e.MESSAGES=void 0;const n=r(4977);e.MESSAGES=function(){return{MS:{},MSroots:{},font:{},embellish:{},role:{},enclose:{},navigate:{},regexp:{},unitTimes:""}},e.NUMBERS=function(){return{zero:"zero",ones:[],tens:[],large:[],special:{},wordOrdinal:n.identityTransformer,numericOrdinal:n.identityTransformer,numberToWords:n.identityTransformer,numberToOrdinal:n.pluralCase,vulgarSep:" ",numSep:" "}},e.ALPHABETS=function(){return{latinSmall:[],latinCap:[],greekSmall:[],greekCap:[],capPrefix:{default:""},smallPrefix:{default:""},digitPrefix:{default:""},languagePrefix:{},digitTrans:{default:n.identityTransformer,mathspeak:n.identityTransformer,clearspeak:n.identityTransformer},letterTrans:{default:n.identityTransformer},combiner:(t,e,r)=>t}},e.FUNCTIONS=function(){return{fracNestDepth:t=>n.vulgarFractionSmall(t,10,100),radicalNestDepth:t=>"",combineRootIndex:function(t,e){return t},combineNestedFraction:n.Combiners.identityCombiner,combineNestedRadical:n.Combiners.identityCombiner,fontRegexp:function(t){return new RegExp("^"+t.split(/ |-/).join("( |-)")+"( |-)")},si:n.siCombiner,plural:n.identityTransformer}},e.SUBISO=function(){return{default:"",current:"",all:[]}}},614:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(2105);function o(t){const e=t%1e3,r=Math.floor(e/100),n=r?1===r?"cent":a.ones[r]+"-cents":"",o=function(t){const e=t%100;if(e<20)return a.ones[e];const r=Math.floor(e/10),n=a.tens[r],o=a.ones[e%10];return n&&o?n+(2===r?"-i-":"-")+o:n||o}(e%100);return n&&o?n+a.numSep+o:n||o}function i(t){if(0===t)return a.zero;if(t>=Math.pow(10,36))return t.toString();let e=0,r="";for(;t>0;){const n=t%(e>1?1e6:1e3);if(n){let t=a.large[e];if(e)if(1===e)r=(1===n?"":o(n)+a.numSep)+t+(r?a.numSep+r:"");else{const e=i(n);t=1===n?t:t.replace(/\u00f3$/,"ons"),r=e+a.numSep+t+(r?a.numSep+r:"")}else r=o(n)}t=Math.floor(t/(e>1?1e6:1e3)),e++}return r}function s(t){const e=n.Grammar.getInstance().getParameter("gender");return t.toString()+("f"===e?"a":"n")}const a=(0,r(7549).NUMBERS)();a.numericOrdinal=s,a.numberToWords=i,a.numberToOrdinal=function(t,e){if(t>1999)return s(t);if(t<=10)return a.special.onesOrdinals[t-1];const r=i(t);return r.match(/mil$/)?r.replace(/mil$/,"mil\xb7l\xe8sima"):r.match(/u$/)?r.replace(/u$/,"vena"):r.match(/a$/)?r.replace(/a$/,"ena"):r+(r.match(/e$/)?"na":"ena")},e.default=a},3866:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});function n(t,e=!1){return t===a.ones[1]?e?"et":"en":t}function o(t,e=!1){let r=t%1e3,o="",i=a.ones[Math.floor(r/100)];if(o+=i?n(i,!0)+" hundrede":"",r%=100,r)if(o+=o?" og ":"",i=e?a.special.smallOrdinals[r]:a.ones[r],i)o+=i;else{const t=e?a.special.tensOrdinals[Math.floor(r/10)]:a.tens[Math.floor(r/10)];i=a.ones[r%10],o+=i?n(i)+"og"+t:t}return o}function i(t,e=!1){if(0===t)return a.zero;if(t>=Math.pow(10,36))return t.toString();let r=0,i="";for(;t>0;){const s=t%1e3;if(s){const t=o(s,e&&!r);if(r){const e=a.large[r],o=s>1?"er":"";i=n(t,r<=1)+" "+e+o+(i?" og ":"")+i}else i=n(t)+i}t=Math.floor(t/1e3),r++}return i}function s(t){if(t%100)return i(t,!0);const e=i(t);return e.match(/e$/)?e:e+"e"}const a=(0,r(7549).NUMBERS)();a.wordOrdinal=s,a.numericOrdinal=function(t){return t.toString()+"."},a.numberToWords=i,a.numberToOrdinal=function(t,e){return 1===t?e?"hel":"hele":2===t?e?"halv":"halve":s(t)+(e?"dele":"del")},e.default=a},1435:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});function n(t,e=!1){return t===a.ones[1]?e?"eine":"ein":t}function o(t){let e=t%1e3,r="",o=a.ones[Math.floor(e/100)];if(r+=o?n(o)+"hundert":"",e%=100,e)if(r+=r?a.numSep:"",o=a.ones[e],o)r+=o;else{const t=a.tens[Math.floor(e/10)];o=a.ones[e%10],r+=o?n(o)+"und"+t:t}return r}function i(t){if(0===t)return a.zero;if(t>=Math.pow(10,36))return t.toString();let e=0,r="";for(;t>0;){const i=t%1e3;if(i){const s=o(t%1e3);if(e){const t=a.large[e],o=e>1&&i>1?t.match(/e$/)?"n":"en":"";r=n(s,e>1)+t+o+r}else r=n(s,e>1)+r}t=Math.floor(t/1e3),e++}return r.replace(/ein$/,"eins")}function s(t){if(1===t)return"erste";if(3===t)return"dritte";if(7===t)return"siebte";if(8===t)return"achte";return i(t)+(t<19?"te":"ste")}const a=(0,r(7549).NUMBERS)();a.wordOrdinal=s,a.numericOrdinal=function(t){return t.toString()+"."},a.numberToWords=i,a.numberToOrdinal=function(t,e){return 1===t?"eintel":2===t?e?"halbe":"halb":s(t)+"l"},e.default=a},310:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});function n(t){let e=t%1e3,r="";return r+=s.ones[Math.floor(e/100)]?s.ones[Math.floor(e/100)]+s.numSep+"hundred":"",e%=100,e&&(r+=r?s.numSep:"",r+=s.ones[e]||s.tens[Math.floor(e/10)]+(e%10?s.numSep+s.ones[e%10]:"")),r}function o(t){if(0===t)return s.zero;if(t>=Math.pow(10,36))return t.toString();let e=0,r="";for(;t>0;){t%1e3&&(r=n(t%1e3)+(e?"-"+s.large[e]+"-":"")+r),t=Math.floor(t/1e3),e++}return r.replace(/-$/,"")}function i(t){let e=o(t);return e.match(/one$/)?e=e.slice(0,-3)+"first":e.match(/two$/)?e=e.slice(0,-3)+"second":e.match(/three$/)?e=e.slice(0,-5)+"third":e.match(/five$/)?e=e.slice(0,-4)+"fifth":e.match(/eight$/)?e=e.slice(0,-5)+"eighth":e.match(/nine$/)?e=e.slice(0,-4)+"ninth":e.match(/twelve$/)?e=e.slice(0,-6)+"twelfth":e.match(/ty$/)?e=e.slice(0,-2)+"tieth":e+="th",e}const s=(0,r(7549).NUMBERS)();s.wordOrdinal=i,s.numericOrdinal=function(t){const e=t%100,r=t.toString();if(e>10&&e<20)return r+"th";switch(t%10){case 1:return r+"st";case 2:return r+"nd";case 3:return r+"rd";default:return r+"th"}},s.numberToWords=o,s.numberToOrdinal=function(t,e){if(1===t)return e?"oneths":"oneth";if(2===t)return e?"halves":"half";const r=i(t);return e?r+"s":r},e.default=s},4634:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(2105);function o(t){const e=t%1e3,r=Math.floor(e/100),n=i.special.hundreds[r],o=function(t){const e=t%100;if(e<30)return i.ones[e];const r=i.tens[Math.floor(e/10)],n=i.ones[e%10];return r&&n?r+" y "+n:r||n}(e%100);return 1===r?o?n+"to "+o:n:n&&o?n+" "+o:n||o}const i=(0,r(7549).NUMBERS)();i.numericOrdinal=function(t){const e=n.Grammar.getInstance().getParameter("gender");return t.toString()+("f"===e?"a":"o")},i.numberToWords=function(t){if(0===t)return i.zero;if(t>=Math.pow(10,36))return t.toString();let e=0,r="";for(;t>0;){const n=t%1e3;if(n){let t=i.large[e];const s=o(n);e?1===n?(t=t.match("/^mil( |$)/")?t:"un "+t,r=t+(r?" "+r:"")):(t=t.replace(/\u00f3n$/,"ones"),r=o(n)+" "+t+(r?" "+r:"")):r=s}t=Math.floor(t/1e3),e++}return r},i.numberToOrdinal=function(t,e){if(t>1999)return t.toString()+"a";if(t<=12)return i.special.onesOrdinals[t-1];const r=[];if(t>=1e3&&(t-=1e3,r.push("mil\xe9sima")),!t)return r.join(" ");let n=0;return n=Math.floor(t/100),n>0&&(r.push(i.special.hundredsOrdinals[n-1]),t%=100),t<=12?r.push(i.special.onesOrdinals[t-1]):(n=Math.floor(t/10),n>0&&(r.push(i.special.tensOrdinals[n-1]),t%=10),t>0&&r.push(i.special.onesOrdinals[t-1])),r.join(" ")},e.default=i},2350:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(5897),o=r(2105),i=r(7549);function s(t){let e=t%1e3,r="";if(r+=u.ones[Math.floor(e/100)]?u.ones[Math.floor(e/100)]+"-cent":"",e%=100,e){r+=r?"-":"";let t=u.ones[e];if(t)r+=t;else{const n=u.tens[Math.floor(e/10)];n.match(/-dix$/)?(t=u.ones[e%10+10],r+=n.replace(/-dix$/,"")+"-"+t):r+=n+(e%10?"-"+u.ones[e%10]:"")}}const n=r.match(/s-\w+$/);return n?r.replace(/s-\w+$/,n[0].slice(1)):r.replace(/-un$/,"-et-un")}function a(t){if(0===t)return u.zero;if(t>=Math.pow(10,36))return t.toString();u.special["tens-"+n.default.getInstance().subiso]&&(u.tens=u.special["tens-"+n.default.getInstance().subiso]);let e=0,r="";for(;t>0;){const n=t%1e3;if(n){let t=u.large[e];const o=s(n);if(t&&t.match(/^mille /)){const n=t.replace(/^mille /,"");r=r.match(RegExp(n))?o+(e?"-mille-":"")+r:r.match(RegExp(n.replace(/s$/,"")))?o+(e?"-mille-":"")+r.replace(n.replace(/s$/,""),n):o+(e?"-"+t+"-":"")+r}else t=1===n&&t?t.replace(/s$/,""):t,r=o+(e?"-"+t+"-":"")+r}t=Math.floor(t/1e3),e++}return r.replace(/-$/,"")}const l={1:"uni\xe8me",2:"demi",3:"tiers",4:"quart"};function c(t){if(1===t)return"premi\xe8re";let e=a(t);return e.match(/^neuf$/)?e=e.slice(0,-1)+"v":e.match(/cinq$/)?e+="u":e.match(/trois$/)?e+="":(e.match(/e$/)||e.match(/s$/))&&(e=e.slice(0,-1)),e+="i\xe8me",e}const u=(0,i.NUMBERS)();u.wordOrdinal=c,u.numericOrdinal=function(t){const e=o.Grammar.getInstance().getParameter("gender");return 1===t?t.toString()+("m"===e?"er":"re"):t.toString()+"e"},u.numberToWords=a,u.numberToOrdinal=function(t,e){const r=l[t]||c(t);return 3===t?r:e?r+"s":r},e.default=u},4438:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(2105);function o(t){if(0===t)return s.zero;if(t>=Math.pow(10,32))return t.toString();let e=0,r="";const n=function(t){let e=t%1e3,r="";return r+=s.ones[Math.floor(e/100)]?s.ones[Math.floor(e/100)]+s.numSep+s.special.hundred:"",e%=100,e&&(r+=r?s.numSep:"",r+=s.ones[e]),r}(t%1e3);if(!(t=Math.floor(t/1e3)))return n;for(;t>0;){const n=t%100;n&&(r=s.ones[n]+s.numSep+s.large[e]+(r?s.numSep+r:"")),t=Math.floor(t/100),e++}return n?r+s.numSep+n:r}function i(t){const e=n.Grammar.getInstance().getParameter("gender");if(t<=0)return t.toString();if(t<10)return"f"===e?s.special.ordinalsFeminine[t]:s.special.ordinalsMasculine[t];return o(t)+("f"===e?"\u0935\u0940\u0902":"\u0935\u093e\u0901")}const s=(0,r(7549).NUMBERS)();s.wordOrdinal=i,s.numericOrdinal=function(t){const e=n.Grammar.getInstance().getParameter("gender");return t>0&&t<10?"f"===e?s.special.simpleSmallOrdinalsFeminine[t]:s.special.simpleSmallOrdinalsMasculine[t]:t.toString().split("").map((function(t){const e=parseInt(t,10);return isNaN(e)?"":s.special.simpleNumbers[e]})).join("")+("f"===e?"\u0935\u0940\u0902":"\u0935\u093e\u0901")},s.numberToWords=o,s.numberToOrdinal=function(t,e){return t<=10?s.special.smallDenominators[t]:i(t)+" \u0905\u0902\u0936"},e.default=s},8825:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(2105);function o(t){let e=t%1e3,r="";if(r+=a.ones[Math.floor(e/100)]?a.ones[Math.floor(e/100)]+a.numSep+"cento":"",e%=100,e){r+=r?a.numSep:"";const t=a.ones[e];if(t)r+=t;else{let t=a.tens[Math.floor(e/10)];const n=e%10;1!==n&&8!==n||(t=t.slice(0,-1)),r+=t,r+=n?a.numSep+a.ones[e%10]:""}}return r}function i(t){if(0===t)return a.zero;if(t>=Math.pow(10,36))return t.toString();if(1===t&&n.Grammar.getInstance().getParameter("fraction"))return"un";let e=0,r="";for(;t>0;){t%1e3&&(r=o(t%1e3)+(e?"-"+a.large[e]+"-":"")+r),t=Math.floor(t/1e3),e++}return r.replace(/-$/,"")}function s(t){const e="m"===n.Grammar.getInstance().getParameter("gender")?"o":"a";let r=a.special.onesOrdinals[t];return r?r.slice(0,-1)+e:(r=i(t),r.slice(0,-1)+"esim"+e)}const a=(0,r(7549).NUMBERS)();a.wordOrdinal=s,a.numericOrdinal=function(t){const e=n.Grammar.getInstance().getParameter("gender");return t.toString()+("m"===e?"o":"a")},a.numberToWords=i,a.numberToOrdinal=function(t,e){if(2===t)return e?"mezzi":"mezzo";const r=s(t);if(!e)return r;const n=r.match(/o$/)?"i":"e";return r.slice(0,-1)+n},e.default=a},3720:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});function n(t){return t.toString().split("").map((function(t){return o.ones[parseInt(t,10)]})).join("")}const o=(0,r(7549).NUMBERS)();o.numberToWords=n,o.numberToOrdinal=n,e.default=o},8274:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(5897);function o(t,e=!1){let r=t%1e3,n="";const o=Math.floor(r/100),s=a.ones[o];if(n+=s?(1===o?"":s)+"hundre":"",r%=100,r){if(n+=n?"og":"",e){const t=a.special.smallOrdinals[r];if(t)return n+t;if(r%10)return n+a.tens[Math.floor(r/10)]+a.special.smallOrdinals[r%10]}n+=a.ones[r]||a.tens[Math.floor(r/10)]+(r%10?a.ones[r%10]:"")}return e?i(n):n}function i(t){const e=a.special.endOrdinal[0];return"a"===e&&t.match(/en$/)?t.slice(0,-2)+a.special.endOrdinal:t.match(/(d|n)$/)||t.match(/hundre$/)?t+"de":t.match(/i$/)?t+a.special.endOrdinal:"a"===e&&t.match(/e$/)?t.slice(0,-1)+a.special.endOrdinal:(t.match(/e$/),t+"nde")}function s(t){return u(t,!0)}const a=(0,r(7549).NUMBERS)();function l(t,e=!1){return t===a.ones[1]?"ein"===t?"eitt ":e?"et":"ett":t}function c(t,e=!1){let r=t%1e3,n="",o=a.ones[Math.floor(r/100)];if(n+=o?l(o)+"hundre":"",r%=100,r){if(n+=n?"og":"",e){const t=a.special.smallOrdinals[r];if(t)return n+t}if(o=a.ones[r],o)n+=o;else{const t=a.tens[Math.floor(r/10)];o=a.ones[r%10],n+=o?o+"og"+t:t}}return e?i(n):n}function u(t,e=!1){const r="alt"===n.default.getInstance().subiso?function(t,e=!1){if(0===t)return e?a.special.smallOrdinals[0]:a.zero;if(t>=Math.pow(10,36))return t.toString();let r=0,n="";for(;t>0;){const o=t%1e3;if(o){const i=c(t%1e3,!r&&e);!r&&e&&(e=!e),n=(1===r?l(i,!0):i)+(r>1?a.numSep:"")+(r?a.large[r]+(r>1&&o>1?"er":""):"")+(r>1&&n?a.numSep:"")+n}t=Math.floor(t/1e3),r++}return e?n+(n.match(/tusen$/)?"de":"te"):n}(t,e):function(t,e=!1){if(0===t)return e?a.special.smallOrdinals[0]:a.zero;if(t>=Math.pow(10,36))return t.toString();let r=0,n="";for(;t>0;){const i=t%1e3;if(i){const s=o(t%1e3,!r&&e);!r&&e&&(e=!e),n=s+(r?" "+a.large[r]+(r>1&&i>1?"er":"")+(n?" ":""):"")+n}t=Math.floor(t/1e3),r++}return e?n+(n.match(/tusen$/)?"de":"te"):n}(t,e);return r}a.wordOrdinal=s,a.numericOrdinal=function(t){return t.toString()+"."},a.numberToWords=u,a.numberToOrdinal=function(t,e){return s(t)},e.default=a},3898:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});function n(t){let e=t%1e3,r="";const n=Math.floor(e/100);return r+=s.ones[n]?(1===n?"":s.ones[n]+s.numSep)+"hundra":"",e%=100,e&&(r+=r?s.numSep:"",r+=s.ones[e]||s.tens[Math.floor(e/10)]+(e%10?s.numSep+s.ones[e%10]:"")),r}function o(t,e=!1){if(0===t)return s.zero;if(t>=Math.pow(10,36))return t.toString();let r=0,o="";for(;t>0;){const i=t%1e3;if(i){const a=s.large[r],l=i>1&&r>1&&!e?"er":"";o=(1===r&&1===i?"":(r>1&&1===i?"en":n(t%1e3))+(r>1?" ":""))+(r?a+l+(r>1?" ":""):"")+o}t=Math.floor(t/1e3),r++}return o.replace(/ $/,"")}function i(t){let e=o(t,!0);return e.match(/^noll$/)?e="nollte":e.match(/ett$/)?e=e.replace(/ett$/,"f\xf6rsta"):e.match(/tv\xe5$/)?e=e.replace(/tv\xe5$/,"andra"):e.match(/tre$/)?e=e.replace(/tre$/,"tredje"):e.match(/fyra$/)?e=e.replace(/fyra$/,"fj\xe4rde"):e.match(/fem$/)?e=e.replace(/fem$/,"femte"):e.match(/sex$/)?e=e.replace(/sex$/,"sj\xe4tte"):e.match(/sju$/)?e=e.replace(/sju$/,"sjunde"):e.match(/\xe5tta$/)?e=e.replace(/\xe5tta$/,"\xe5ttonde"):e.match(/nio$/)?e=e.replace(/nio$/,"nionde"):e.match(/tio$/)?e=e.replace(/tio$/,"tionde"):e.match(/elva$/)?e=e.replace(/elva$/,"elfte"):e.match(/tolv$/)?e=e.replace(/tolv$/,"tolfte"):e.match(/tusen$/)?e=e.replace(/tusen$/,"tusonde"):e.match(/jard$/)||e.match(/jon$/)?e+="te":e+="de",e}const s=(0,r(7549).NUMBERS)();s.wordOrdinal=i,s.numericOrdinal=function(t){const e=t.toString();return e.match(/11$|12$/)?e+":e":e+(e.match(/1$|2$/)?":a":":e")},s.numberToWords=o,s.numberToOrdinal=function(t,e){if(1===t)return"hel";if(2===t)return e?"halva":"halv";let r=i(t);return r=r.match(/de$/)?r.replace(/de$/,""):r,r+(e?"delar":"del")},e.default=s},4977:function(t,e){function r(t,e=""){if(!t.childNodes||!t.childNodes[0]||!t.childNodes[0].childNodes||t.childNodes[0].childNodes.length<2||"number"!==t.childNodes[0].childNodes[0].tagName||"integer"!==t.childNodes[0].childNodes[0].getAttribute("role")||"number"!==t.childNodes[0].childNodes[1].tagName||"integer"!==t.childNodes[0].childNodes[1].getAttribute("role"))return{convertible:!1,content:t.textContent};const r=t.childNodes[0].childNodes[1].textContent,n=t.childNodes[0].childNodes[0].textContent,o=Number(r),i=Number(n);return isNaN(o)||isNaN(i)?{convertible:!1,content:`${n} ${e} ${r}`}:{convertible:!0,enumerator:i,denominator:o}}Object.defineProperty(e,"__esModule",{value:!0}),e.vulgarFractionSmall=e.convertVulgarFraction=e.Combiners=e.siCombiner=e.identityTransformer=e.pluralCase=void 0,e.pluralCase=function(t,e){return t.toString()},e.identityTransformer=function(t){return t.toString()},e.siCombiner=function(t,e){return t+e.toLowerCase()},e.Combiners={},e.Combiners.identityCombiner=function(t,e,r){return t+e+r},e.Combiners.prefixCombiner=function(t,e,r){return t=r?r+" "+t:t,e?e+" "+t:t},e.Combiners.postfixCombiner=function(t,e,r){return t=r?r+" "+t:t,e?t+" "+e:t},e.Combiners.romanceCombiner=function(t,e,r){return t=r?t+" "+r:t,e?t+" "+e:t},e.convertVulgarFraction=r,e.vulgarFractionSmall=function(t,e,n){const o=r(t);if(o.convertible){const t=o.enumerator,r=o.denominator;return t>0&&t0&&r{const s=this.parseCstr(e.toString().replace(o,""));this.addRule(new i.SpeechRule(t,s,n,r))}))}getFullPreconditions(t){const e=this.preconditions.get(t);return e||!this.inherits?e:this.inherits.getFullPreconditions(t)}definePrecondition(t,e,r,...n){const o=this.parsePrecondition(r,n),i=this.parseCstr(e);o&&i?(o.rank=this.rank++,this.preconditions.set(t,new l(i,o))):console.error(`Precondition Error: ${r}, (${e})`)}inheritRules(){if(!this.inherits||!this.inherits.getSpeechRules().length)return;const t=new RegExp("^\\w+\\.\\w+\\."+(this.domain?"\\w+\\.":""));this.inherits.getSpeechRules().forEach((e=>{const r=this.parseCstr(e.dynamicCstr.toString().replace(t,""));this.addRule(new i.SpeechRule(e.name,r,e.precondition,e.action))}))}ignoreRules(t,...e){let r=this.findAllRules((e=>e.name===t));if(!e.length)return void r.forEach(this.deleteRule.bind(this));let n=[];for(const t of e){const e=this.parseCstr(t);for(const t of r)e.equal(t.dynamicCstr)?this.deleteRule(t):n.push(t);r=n,n=[]}}parsePrecondition_(t){const e=this.context.customGenerators.lookup(t);return e?e():[t]}}e.BaseRuleStore=a;class l{constructor(t,e){this.base=t,this._conditions=[],this.constraints=[],this.allCstr={},this.constraints.push(t),this.addCondition(t,e)}get conditions(){return this._conditions}addConstraint(t){if(this.constraints.filter((e=>e.equal(t))).length)return;this.constraints.push(t);const e=[];for(const[r,n]of this.conditions)this.base.equal(r)&&e.push([t,n]);this._conditions=this._conditions.concat(e)}addBaseCondition(t){this.addCondition(this.base,t)}addFullCondition(t){this.constraints.forEach((e=>this.addCondition(e,t)))}addCondition(t,e){const r=t.toString()+" "+e.toString();this.allCstr.condStr||(this.allCstr[r]=!0,this._conditions.push([t,e]))}}e.Condition=l},2469:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.BrailleStore=void 0;const n=r(7630),o=r(9935);class i extends o.MathStore{constructor(){super(...arguments),this.modality="braille",this.customTranscriptions={"\u22ca":"\u2808\u2821\u2833"}}evaluateString(t){const e=[],r=Array.from(t);for(let t=0;tt.push(this.getProperty(e).slice()))),t}toString(){const t=[];return this.order.forEach((e=>t.push(e+": "+this.getProperty(e).toString()))),t.join("\n")}}e.DynamicProperties=n;class o extends n{constructor(t,e){const r={};for(const[e,n]of Object.entries(t))r[e]=[n];super(r,e),this.components=t}static createCstr(...t){const e=o.DEFAULT_ORDER,r={};for(let n=0,o=t.length,i=e.length;n{const r=e.indexOf(t);return-1!==r&&e.splice(r,1)}))}getComponents(){return this.components}getValue(t){return this.components[t]}getValues(){return this.order.map((t=>this.getValue(t)))}allProperties(){const t=super.allProperties();for(let e,r,n=0;e=t[n],r=this.order[n];n++){const t=this.getValue(r);-1===e.indexOf(t)&&e.unshift(t)}return t}toString(){return this.getValues().join(".")}equal(t){const e=t.getAxes();if(this.order.length!==e.length)return!1;for(let r,n=0;r=e[n];n++){const e=this.getValue(r);if(!e||t.getValue(r)!==e)return!1}return!0}}e.DynamicCstr=o,o.DEFAULT_ORDER=[r.LOCALE,r.MODALITY,r.DOMAIN,r.STYLE,r.TOPIC],o.BASE_LOCALE="base",o.DEFAULT_VALUE="default",o.DEFAULT_VALUES={[r.LOCALE]:"en",[r.DOMAIN]:o.DEFAULT_VALUE,[r.STYLE]:o.DEFAULT_VALUE,[r.TOPIC]:o.DEFAULT_VALUE,[r.MODALITY]:"speech"};e.DynamicCstrParser=class{constructor(t){this.order=t}parse(t){const e=t.split("."),r={};if(e.length>this.order.length)throw new Error("Invalid dynamic constraint: "+r);let n=0;for(let t,o=0;t=this.order[o],e.length;o++,n++){const n=e.shift();r[t]=n}return new o(r,this.order.slice(0,n))}};e.DefaultComparator=class{constructor(t,e=new n(t.getProperties(),t.getOrder())){this.reference=t,this.fallback=e,this.order=this.reference.getOrder()}getReference(){return this.reference}setReference(t,e){this.reference=t,this.fallback=e||new n(t.getProperties(),t.getOrder()),this.order=this.reference.getOrder()}match(t){const e=t.getAxes();return e.length===this.reference.getAxes().length&&e.every((e=>{const r=t.getValue(e);return r===this.reference.getValue(e)||-1!==this.fallback.getProperty(e).indexOf(r)}))}compare(t,e){let r=!1;for(let n,o=0;n=this.order[o];o++){const o=t.getValue(n),i=e.getValue(n);if(!r){const t=this.reference.getValue(n);if(t===o&&t!==i)return-1;if(t===i&&t!==o)return 1;if(t===o&&t===i)continue;t!==o&&t!==i&&(r=!0)}const s=this.fallback.getProperty(n),a=s.indexOf(o),l=s.indexOf(i);if(a!h.equal(t.cstr))),l.push(m),this.rules.set(e,l),f.setReference(d)}lookupRule(t,e){let r=this.getRules(e.getValue(o.Axis.LOCALE));return r=r.filter((function(t){return i.testDynamicConstraints_(e,t)})),1===r.length?r[0]:r.length?r.sort(((t,e)=>n.default.getInstance().comparator.compare(t.cstr,e.cstr)))[0]:null}}e.MathSimpleStore=i},9935:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.MathStore=void 0;const n=r(707),o=r(4356),i=r(7630),s=r(4504),a=r(4650);class l extends s.BaseRuleStore{constructor(){super(),this.annotators=[],this.parseMethods.Alias=this.defineAlias,this.parseMethods.SpecializedRule=this.defineSpecializedRule,this.parseMethods.Specialized=this.defineSpecialized}initialize(){this.initialized||(this.annotations(),this.initialized=!0)}annotations(){for(let t,e=0;t=this.annotators[e];e++)(0,i.activate)(this.domain,t)}defineAlias(t,e,...r){const n=this.parsePrecondition(e,r);if(!n)return void console.error(`Precondition Error: ${e} ${r}`);const o=this.preconditions.get(t);o?o.addFullCondition(n):console.error(`Alias Error: No precondition by the name of ${t}`)}defineRulesAlias(t,e,...r){const n=this.findAllRules((function(e){return e.name===t}));if(0===n.length)throw new a.OutputError("Rule with name "+t+" does not exist.");const o=[];n.forEach((t=>{(t=>{const e=t.dynamicCstr.toString(),r=t.action.toString();for(let t,n=0;t=o[n];n++)if(t.action===r&&t.cstr===e)return!1;return o.push({cstr:e,action:r}),!0})(t)&&this.addAlias_(t,e,r)}))}defineSpecializedRule(t,e,r,n){const o=this.parseCstr(e),i=this.findRule((e=>e.name===t&&o.equal(e.dynamicCstr))),s=this.parseCstr(r);if(!i&&n)throw new a.OutputError("Rule named "+t+" with style "+e+" does not exist.");const l=n?a.Action.fromString(n):i.action,c=new a.SpeechRule(i.name,s,i.precondition,l);this.addRule(c)}defineSpecialized(t,e,r){const n=this.parseCstr(r);if(!n)return void console.error(`Dynamic Constraint Error: ${r}`);const o=this.preconditions.get(t);o?o.addConstraint(n):console.error(`Alias Error: No precondition by the name of ${t}`)}evaluateString(t){const e=[];if(t.match(/^\s+$/))return e;let r=this.matchNumber_(t);if(r&&r.length===t.length)return e.push(this.evaluateCharacter(r.number)),e;const i=n.removeEmpty(t.replace(/\s/g," ").split(" "));for(let t,n=0;t=i[n];n++)if(1===t.length)e.push(this.evaluateCharacter(t));else if(t.match(new RegExp("^["+o.LOCALE.MESSAGES.regexp.TEXT+"]+$")))e.push(this.evaluateCharacter(t));else{let n=t;for(;n;){r=this.matchNumber_(n);const t=n.match(new RegExp("^["+o.LOCALE.MESSAGES.regexp.TEXT+"]+"));if(r)e.push(this.evaluateCharacter(r.number)),n=n.substring(r.length);else if(t)e.push(this.evaluateCharacter(t[0])),n=n.substring(t[0].length);else{const t=Array.from(n),r=t[0];e.push(this.evaluateCharacter(r)),n=t.slice(1).join("")}}}return e}parse(t){super.parse(t),this.annotators=t.annotators||[]}addAlias_(t,e,r){const n=this.parsePrecondition(e,r),o=new a.SpeechRule(t.name,t.dynamicCstr,n,t.action);o.name=t.name,this.addRule(o)}matchNumber_(t){const e=t.match(new RegExp("^"+o.LOCALE.MESSAGES.regexp.NUMBER)),r=t.match(new RegExp("^"+l.regexp.NUMBER));if(!e&&!r)return null;const n=r&&r[0]===t;if(e&&e[0]===t||!n)return e?{number:e[0],length:e[0].length}:null;return{number:r[0].replace(new RegExp(l.regexp.DIGIT_GROUP,"g"),"X").replace(new RegExp(l.regexp.DECIMAL_MARK,"g"),o.LOCALE.MESSAGES.regexp.DECIMAL_MARK).replace(/X/g,o.LOCALE.MESSAGES.regexp.DIGIT_GROUP.replace(/\\/g,"")),length:r[0].length}}}e.MathStore=l,l.regexp={NUMBER:"((\\d{1,3})(?=(,| ))((,| )\\d{3})*(\\.\\d+)?)|^\\d*\\.\\d+|^\\d+",DECIMAL_MARK:"\\.",DIGIT_GROUP:","}},4650:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.OutputError=e.Precondition=e.Action=e.Component=e.ActionType=e.SpeechRule=void 0;const n=r(5897),o=r(2105);var i;function s(t){switch(t){case"[n]":return i.NODE;case"[m]":return i.MULTI;case"[t]":return i.TEXT;case"[p]":return i.PERSONALITY;default:throw"Parse error: "+t}}e.SpeechRule=class{constructor(t,e,r,n){this.name=t,this.dynamicCstr=e,this.precondition=r,this.action=n,this.context=null}toString(){return this.name+" | "+this.dynamicCstr.toString()+" | "+this.precondition.toString()+" ==> "+this.action.toString()}},function(t){t.NODE="NODE",t.MULTI="MULTI",t.TEXT="TEXT",t.PERSONALITY="PERSONALITY"}(i=e.ActionType||(e.ActionType={}));class a{constructor({type:t,content:e,attributes:r,grammar:n}){this.type=t,this.content=e,this.attributes=r,this.grammar=n}static grammarFromString(t){return o.Grammar.parseInput(t)}static fromString(t){const e={type:s(t.substring(0,3))};let r=t.slice(3).trim();if(!r)throw new u("Missing content.");switch(e.type){case i.TEXT:if('"'===r[0]){const t=p(r,"\\(")[0].trim();if('"'!==t.slice(-1))throw new u("Invalid string syntax.");e.content=t,r=r.slice(t.length).trim(),-1===r.indexOf("(")&&(r="");break}case i.NODE:case i.MULTI:{const t=r.indexOf(" (");if(-1===t){e.content=r.trim(),r="";break}e.content=r.substring(0,t).trim(),r=r.slice(t).trim()}}if(r){const t=a.attributesFromString(r);t.grammar&&(e.grammar=t.grammar,delete t.grammar),Object.keys(t).length&&(e.attributes=t)}return new a(e)}static attributesFromString(t){if("("!==t[0]||")"!==t.slice(-1))throw new u("Invalid attribute expression: "+t);const e={},r=p(t.slice(1,-1),",");for(let t=0,n=r.length;t0?"("+t.join(", ")+")":""}getAttributes(){const t=[];for(const e in this.attributes){const r=this.attributes[e];"true"===r?t.push(e):t.push(e+":"+r)}return t}}e.Component=a;class l{constructor(t){this.components=t}static fromString(t){const e=p(t,";").filter((function(t){return t.match(/\S/)})).map((function(t){return t.trim()})),r=[];for(let t=0,n=e.length;t0?r[0]:null}applyConstraint(t,e){return!!this.applyQuery(t,e)||n.evaluateBoolean(e,t)}constructString(t,e){if(!e)return"";if('"'===e.charAt(0))return e.slice(1,-1);const r=this.customStrings.lookup(e);return r?r(t):n.evaluateString(e,t)}parse(t){const e=Array.isArray(t)?t:Object.entries(t);for(let t,r=0;t=e[r];r++){switch(t[0].slice(0,3)){case"CQF":this.customQueries.add(t[0],t[1]);break;case"CSF":this.customStrings.add(t[0],t[1]);break;case"CTF":this.contextFunctions.add(t[0],t[1]);break;case"CGF":this.customGenerators.add(t[0],t[1]);break;default:console.error("FunctionError: Invalid function name "+t[0])}}}}},2362:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.storeFactory=e.SpeechRuleEngine=void 0;const n=r(7052),o=r(2057),i=r(5740),s=r(5897),a=r(4440),l=r(5274),c=r(7283),u=r(7599),p=r(2469),h=r(1676),f=r(2105),d=r(9935),m=r(4650),y=r(4508);class g{constructor(){this.trie=null,this.evaluators_={},this.trie=new y.Trie}static getInstance(){return g.instance=g.instance||new g,g.instance}static debugSpeechRule(t,e){const r=t.precondition,n=t.context.applyQuery(e,r.query);o.Debugger.getInstance().output(r.query,n?n.toString():n),r.constraints.forEach((r=>o.Debugger.getInstance().output(r,t.context.applyConstraint(e,r))))}static debugNamedSpeechRule(t,e){const r=g.getInstance().trie.collectRules().filter((e=>e.name==t));for(let n,i=0;n=r[i];i++)o.Debugger.getInstance().output("Rule",t,"DynamicCstr:",n.dynamicCstr.toString(),"number",i),g.debugSpeechRule(n,e)}evaluateNode(t){(0,l.updateEvaluator)(t);const e=(new Date).getTime();let r=[];try{r=this.evaluateNode_(t)}catch(t){console.error("Something went wrong computing speech."),o.Debugger.getInstance().output(t)}const n=(new Date).getTime();return o.Debugger.getInstance().output("Time:",n-e),r}toString(){return this.trie.collectRules().map((t=>t.toString())).join("\n")}runInSetting(t,e){const r=s.default.getInstance(),n={};for(const e in t)n[e]=r[e],r[e]=t[e];r.setDynamicCstr();const o=e();for(const t in n)r[t]=n[t];return r.setDynamicCstr(),o}addStore(t){const e=v(t);"abstract"!==e.kind&&e.getSpeechRules().forEach((t=>this.trie.addRule(t))),this.addEvaluator(e)}processGrammar(t,e,r){const n={};for(const o in r){const i=r[o];n[o]="string"==typeof i?t.constructString(e,i):i}f.Grammar.getInstance().pushState(n)}addEvaluator(t){const e=t.evaluateDefault.bind(t),r=this.evaluators_[t.locale];if(r)return void(r[t.modality]=e);const n={};n[t.modality]=e,this.evaluators_[t.locale]=n}getEvaluator(t,e){const r=this.evaluators_[t]||this.evaluators_[h.DynamicCstr.DEFAULT_VALUES[h.Axis.LOCALE]];return r[e]||r[h.DynamicCstr.DEFAULT_VALUES[h.Axis.MODALITY]]}enumerate(t){return this.trie.enumerate(t)}evaluateNode_(t){return t?(this.updateConstraint_(),this.evaluateTree_(t)):[]}evaluateTree_(t){const e=s.default.getInstance();let r;o.Debugger.getInstance().output(e.mode!==a.Mode.HTTP?t.toString():t),f.Grammar.getInstance().setAttribute(t);const i=this.lookupRule(t,e.dynamicCstr);if(!i)return e.strict?[]:(r=this.getEvaluator(e.locale,e.modality)(t),t.attributes&&this.addPersonality_(r,{},!1,t),r);o.Debugger.getInstance().generateOutput((()=>["Apply Rule:",i.name,i.dynamicCstr.toString(),(e.mode,a.Mode.HTTP,t).toString()]));const c=i.context,u=i.action.components;r=[];for(let e,o=0;e=u[o];o++){let o=[];const i=e.content||"",a=e.attributes||{};let u=!1;e.grammar&&this.processGrammar(c,t,e.grammar);let p=null;if(a.engine){p=s.default.getInstance().dynamicCstr.getComponents();const t=f.Grammar.parseInput(a.engine);s.default.getInstance().setDynamicCstr(t)}switch(e.type){case m.ActionType.NODE:{const e=c.applyQuery(t,i);e&&(o=this.evaluateTree_(e))}break;case m.ActionType.MULTI:{u=!0;const e=c.applySelector(t,i);e.length>0&&(o=this.evaluateNodeList_(c,e,a.sepFunc,c.constructString(t,a.separator),a.ctxtFunc,c.constructString(t,a.context)))}break;case m.ActionType.TEXT:{const e=a.span,r={};if(e){const n=(0,l.evalXPath)(e,t);n.length&&(r.extid=n[0].getAttribute("extid"))}const s=c.constructString(t,i);(s||""===s)&&(o=Array.isArray(s)?s.map((function(t){return n.AuditoryDescription.create({text:t.speech,attributes:t.attributes},{adjust:!0})})):[n.AuditoryDescription.create({text:s,attributes:r},{adjust:!0})])}break;case m.ActionType.PERSONALITY:default:o=[n.AuditoryDescription.create({text:i})]}o[0]&&!u&&(a.context&&(o[0].context=c.constructString(t,a.context)+(o[0].context||"")),a.annotation&&(o[0].annotation=a.annotation)),this.addLayout(o,a,u),e.grammar&&f.Grammar.getInstance().popState(),r=r.concat(this.addPersonality_(o,a,u,t)),p&&s.default.getInstance().setDynamicCstr(p)}return r}evaluateNodeList_(t,e,r,o,i,s){if(!e.length)return[];const a=o||"",l=s||"",c=t.contextFunctions.lookup(i),u=c?c(e,l):function(){return l},p=t.contextFunctions.lookup(r),h=p?p(e,a):function(){return[n.AuditoryDescription.create({text:a},{translate:!0})]};let f=[];for(let t,r=0;t=e[r];r++){const n=this.evaluateTree_(t);if(n.length>0&&(n[0].context=u()+(n[0].context||""),f=f.concat(n),r=0;e--){const n=r[e].name;!t.attributes[n]&&n.match(/^ext/)&&(t.attributes[n]=r[e].value)}}}addRelativePersonality_(t,e){if(!t.personality)return t.personality=e,t;const r=t.personality;for(const t in e)r[t]&&"number"==typeof r[t]&&"number"==typeof e[t]?r[t]=r[t]+e[t]:r[t]||(r[t]=e[t]);return t}updateConstraint_(){const t=s.default.getInstance().dynamicCstr,e=s.default.getInstance().strict,r=this.trie,n={};let o=t.getValue(h.Axis.LOCALE),i=t.getValue(h.Axis.MODALITY),a=t.getValue(h.Axis.DOMAIN);r.hasSubtrie([o,i,a])||(a=h.DynamicCstr.DEFAULT_VALUES[h.Axis.DOMAIN],r.hasSubtrie([o,i,a])||(i=h.DynamicCstr.DEFAULT_VALUES[h.Axis.MODALITY],r.hasSubtrie([o,i,a])||(o=h.DynamicCstr.DEFAULT_VALUES[h.Axis.LOCALE]))),n[h.Axis.LOCALE]=[o],n[h.Axis.MODALITY]=["summary"!==i?i:h.DynamicCstr.DEFAULT_VALUES[h.Axis.MODALITY]],n[h.Axis.DOMAIN]=["speech"!==i?h.DynamicCstr.DEFAULT_VALUES[h.Axis.DOMAIN]:a];const l=t.getOrder();for(let r,o=0;r=l[o];o++)if(!n[r]){const o=t.getValue(r),i=this.makeSet_(o,t.preference),s=h.DynamicCstr.DEFAULT_VALUES[r];e||o===s||i.push(s),n[r]=i}t.updateProperties(n)}makeSet_(t,e){return e&&Object.keys(e).length?t.split(":"):[t]}lookupRule(t,e){if(!t||t.nodeType!==i.NodeType.ELEMENT_NODE&&t.nodeType!==i.NodeType.TEXT_NODE)return null;const r=this.lookupRules(t,e);return r.length>0?this.pickMostConstraint_(e,r):null}lookupRules(t,e){return this.trie.lookupRules(t,e.allProperties())}pickMostConstraint_(t,e){const r=s.default.getInstance().comparator;return e.sort((function(t,e){return r.compare(t.dynamicCstr,e.dynamicCstr)||e.precondition.priority-t.precondition.priority||e.precondition.constraints.length-t.precondition.constraints.length||e.precondition.rank-t.precondition.rank})),o.Debugger.getInstance().generateOutput((()=>e.map((t=>t.name+"("+t.dynamicCstr.toString()+")"))).bind(this)),e[0]}}e.SpeechRuleEngine=g;const b=new Map;function v(t){const e=`${t.locale}.${t.modality}.${t.domain}`;if("actions"===t.kind){const r=b.get(e);return r.parse(t),r}u.init(),t&&!t.functions&&(t.functions=c.getStore(t.locale,t.modality,t.domain));const r="braille"===t.modality?new p.BrailleStore:new d.MathStore;return b.set(e,r),t.inherits&&(r.inherits=b.get(`${t.inherits}.${t.modality}.${t.domain}`)),r.parse(t),r.initialize(),r}e.storeFactory=v,s.default.nodeEvaluator=g.getInstance().evaluateNode.bind(g.getInstance())},5662:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.CustomGenerators=e.ContextFunctions=e.CustomStrings=e.CustomQueries=void 0;class r{constructor(t,e){this.prefix=t,this.store=e}add(t,e){this.checkCustomFunctionSyntax_(t)&&(this.store[t]=e)}addStore(t){const e=Object.keys(t.store);for(let r,n=0;r=e[n];n++)this.add(r,t.store[r])}lookup(t){return this.store[t]}checkCustomFunctionSyntax_(t){const e=new RegExp("^"+this.prefix);return!!t.match(e)||(console.error("FunctionError: Invalid function name. Expected prefix "+this.prefix),!1)}}e.CustomQueries=class extends r{constructor(){super("CQF",{})}};e.CustomStrings=class extends r{constructor(){super("CSF",{})}};e.ContextFunctions=class extends r{constructor(){super("CTF",{})}};e.CustomGenerators=class extends r{constructor(){super("CGF",{})}}},365:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.contentIterator=e.pauseSeparator=e.nodeCounter=void 0;const n=r(7052),o=r(5274),i=r(5897);e.nodeCounter=function(t,e){const r=t.length;let n=0,o=e;return e||(o=""),function(){return n0?o.evalXPath("../../content/*",t[0]):[],function(){const t=r.shift(),o=e?[n.AuditoryDescription.create({text:e},{translate:!0})]:[];if(!t)return o;const s=i.default.evaluateNode(t);return o.concat(s)}}},1414:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.getTreeFromString=e.getTree=e.xmlTree=void 0;const n=r(5740),o=r(7075);function i(t){return new o.SemanticTree(t)}e.xmlTree=function(t){return i(t).xml()},e.getTree=i,e.getTreeFromString=function(t){return i(n.parseInput(t))}},7630:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.annotate=e.activate=e.register=e.visitors=e.annotators=void 0;const n=r(9265);e.annotators=new Map,e.visitors=new Map,e.register=function(t){const r=t.domain+":"+t.name;t instanceof n.SemanticAnnotator?e.annotators.set(r,t):e.visitors.set(r,t)},e.activate=function(t,r){const n=t+":"+r,o=e.annotators.get(n)||e.visitors.get(n);o&&(o.active=!0)},e.annotate=function(t){for(const r of e.annotators.values())r.active&&r.annotate(t);for(const r of e.visitors.values())r.active&&r.visit(t,Object.assign({},r.def))}},9265:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticVisitor=e.SemanticAnnotator=void 0;e.SemanticAnnotator=class{constructor(t,e,r){this.domain=t,this.name=e,this.func=r,this.active=!1}annotate(t){t.childNodes.forEach(this.annotate.bind(this)),t.addAnnotation(this.domain,this.func(t))}};e.SemanticVisitor=class{constructor(t,e,r,n={}){this.domain=t,this.name=e,this.func=r,this.def=n,this.active=!1}visit(t,e){let r=this.func(t,e);t.addAnnotation(this.domain,r[0]);for(let e,n=0;e=t.childNodes[n];n++)r=this.visit(e,r[1]);return r}}},3588:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.lookupSecondary=e.isEmbellishedType=e.isMatchingFence=e.functionApplication=e.invisibleComma=e.invisiblePlus=e.invisibleTimes=e.lookupMeaning=e.lookupRole=e.lookupType=e.equal=e.allLettersRegExp=void 0;const r=String.fromCodePoint(8291),n=["\uff0c","\ufe50",",",r],o=["\xaf","\u2012","\u2013","\u2014","\u2015","\ufe58","-","\u207b","\u208b","\u2212","\u2796","\ufe63","\uff0d","\u2010","\u2011","\u203e","_"],i=["~","\u0303","\u223c","\u02dc","\u223d","\u02f7","\u0334","\u0330"],s={"(":")","[":"]","{":"}","\u2045":"\u2046","\u2329":"\u232a","\u2768":"\u2769","\u276a":"\u276b","\u276c":"\u276d","\u276e":"\u276f","\u2770":"\u2771","\u2772":"\u2773","\u2774":"\u2775","\u27c5":"\u27c6","\u27e6":"\u27e7","\u27e8":"\u27e9","\u27ea":"\u27eb","\u27ec":"\u27ed","\u27ee":"\u27ef","\u2983":"\u2984","\u2985":"\u2986","\u2987":"\u2988","\u2989":"\u298a","\u298b":"\u298c","\u298d":"\u298e","\u298f":"\u2990","\u2991":"\u2992","\u2993":"\u2994","\u2995":"\u2996","\u2997":"\u2998","\u29d8":"\u29d9","\u29da":"\u29db","\u29fc":"\u29fd","\u2e22":"\u2e23","\u2e24":"\u2e25","\u2e26":"\u2e27","\u2e28":"\u2e29","\u3008":"\u3009","\u300a":"\u300b","\u300c":"\u300d","\u300e":"\u300f","\u3010":"\u3011","\u3014":"\u3015","\u3016":"\u3017","\u3018":"\u3019","\u301a":"\u301b","\u301d":"\u301e","\ufd3e":"\ufd3f","\ufe17":"\ufe18","\ufe59":"\ufe5a","\ufe5b":"\ufe5c","\ufe5d":"\ufe5e","\uff08":"\uff09","\uff3b":"\uff3d","\uff5b":"\uff5d","\uff5f":"\uff60","\uff62":"\uff63","\u2308":"\u2309","\u230a":"\u230b","\u230c":"\u230d","\u230e":"\u230f","\u231c":"\u231d","\u231e":"\u231f","\u239b":"\u239e","\u239c":"\u239f","\u239d":"\u23a0","\u23a1":"\u23a4","\u23a2":"\u23a5","\u23a3":"\u23a6","\u23a7":"\u23ab","\u23a8":"\u23ac","\u23a9":"\u23ad","\u23b0":"\u23b1","\u23b8":"\u23b9"},a={"\u23b4":"\u23b5","\u23dc":"\u23dd","\u23de":"\u23df","\u23e0":"\u23e1","\ufe35":"\ufe36","\ufe37":"\ufe38","\ufe39":"\ufe3a","\ufe3b":"\ufe3c","\ufe3d":"\ufe3e","\ufe3f":"\ufe40","\ufe41":"\ufe42","\ufe43":"\ufe44","\ufe47":"\ufe48"},l=Object.keys(s),c=Object.values(s);c.push("\u301f");const u=Object.keys(a),p=Object.values(a),h=["|","\xa6","\u2223","\u23d0","\u23b8","\u23b9","\u2758","\uff5c","\uffe4","\ufe31","\ufe32"],f=["\u2016","\u2225","\u2980","\u2af4"],d=["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"],m=["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","\u0131","\u0237"],y=["\uff21","\uff22","\uff23","\uff24","\uff25","\uff26","\uff27","\uff28","\uff29","\uff2a","\uff2b","\uff2c","\uff2d","\uff2e","\uff2f","\uff30","\uff31","\uff32","\uff33","\uff34","\uff35","\uff36","\uff37","\uff38","\uff39","\uff3a"],g=["\uff41","\uff42","\uff43","\uff44","\uff45","\uff46","\uff47","\uff48","\uff49","\uff4a","\uff4b","\uff4c","\uff4d","\uff4e","\uff4f","\uff50","\uff51","\uff52","\uff53","\uff54","\uff55","\uff56","\uff57","\uff58","\uff59","\uff5a"],b=["\ud835\udc00","\ud835\udc01","\ud835\udc02","\ud835\udc03","\ud835\udc04","\ud835\udc05","\ud835\udc06","\ud835\udc07","\ud835\udc08","\ud835\udc09","\ud835\udc0a","\ud835\udc0b","\ud835\udc0c","\ud835\udc0d","\ud835\udc0e","\ud835\udc0f","\ud835\udc10","\ud835\udc11","\ud835\udc12","\ud835\udc13","\ud835\udc14","\ud835\udc15","\ud835\udc16","\ud835\udc17","\ud835\udc18","\ud835\udc19"],v=["\ud835\udc1a","\ud835\udc1b","\ud835\udc1c","\ud835\udc1d","\ud835\udc1e","\ud835\udc1f","\ud835\udc20","\ud835\udc21","\ud835\udc22","\ud835\udc23","\ud835\udc24","\ud835\udc25","\ud835\udc26","\ud835\udc27","\ud835\udc28","\ud835\udc29","\ud835\udc2a","\ud835\udc2b","\ud835\udc2c","\ud835\udc2d","\ud835\udc2e","\ud835\udc2f","\ud835\udc30","\ud835\udc31","\ud835\udc32","\ud835\udc33"],_=["\ud835\udc34","\ud835\udc35","\ud835\udc36","\ud835\udc37","\ud835\udc38","\ud835\udc39","\ud835\udc3a","\ud835\udc3b","\ud835\udc3c","\ud835\udc3d","\ud835\udc3e","\ud835\udc3f","\ud835\udc40","\ud835\udc41","\ud835\udc42","\ud835\udc43","\ud835\udc44","\ud835\udc45","\ud835\udc46","\ud835\udc47","\ud835\udc48","\ud835\udc49","\ud835\udc4a","\ud835\udc4b","\ud835\udc4c","\ud835\udc4d"],S=["\ud835\udc4e","\ud835\udc4f","\ud835\udc50","\ud835\udc51","\ud835\udc52","\ud835\udc53","\ud835\udc54","\u210e","\ud835\udc56","\ud835\udc57","\ud835\udc58","\ud835\udc59","\ud835\udc5a","\ud835\udc5b","\ud835\udc5c","\ud835\udc5d","\ud835\udc5e","\ud835\udc5f","\ud835\udc60","\ud835\udc61","\ud835\udc62","\ud835\udc63","\ud835\udc64","\ud835\udc65","\ud835\udc66","\ud835\udc67","\ud835\udea4","\ud835\udea5"],M=["\ud835\udc68","\ud835\udc69","\ud835\udc6a","\ud835\udc6b","\ud835\udc6c","\ud835\udc6d","\ud835\udc6e","\ud835\udc6f","\ud835\udc70","\ud835\udc71","\ud835\udc72","\ud835\udc73","\ud835\udc74","\ud835\udc75","\ud835\udc76","\ud835\udc77","\ud835\udc78","\ud835\udc79","\ud835\udc7a","\ud835\udc7b","\ud835\udc7c","\ud835\udc7d","\ud835\udc7e","\ud835\udc7f","\ud835\udc80","\ud835\udc81"],O=["\ud835\udc82","\ud835\udc83","\ud835\udc84","\ud835\udc85","\ud835\udc86","\ud835\udc87","\ud835\udc88","\ud835\udc89","\ud835\udc8a","\ud835\udc8b","\ud835\udc8c","\ud835\udc8d","\ud835\udc8e","\ud835\udc8f","\ud835\udc90","\ud835\udc91","\ud835\udc92","\ud835\udc93","\ud835\udc94","\ud835\udc95","\ud835\udc96","\ud835\udc97","\ud835\udc98","\ud835\udc99","\ud835\udc9a","\ud835\udc9b"],x=["\ud835\udc9c","\u212c","\ud835\udc9e","\ud835\udc9f","\u2130","\u2131","\ud835\udca2","\u210b","\u2110","\ud835\udca5","\ud835\udca6","\u2112","\u2133","\ud835\udca9","\ud835\udcaa","\ud835\udcab","\ud835\udcac","\u211b","\ud835\udcae","\ud835\udcaf","\ud835\udcb0","\ud835\udcb1","\ud835\udcb2","\ud835\udcb3","\ud835\udcb4","\ud835\udcb5","\u2118"],E=["\ud835\udcb6","\ud835\udcb7","\ud835\udcb8","\ud835\udcb9","\u212f","\ud835\udcbb","\u210a","\ud835\udcbd","\ud835\udcbe","\ud835\udcbf","\ud835\udcc0","\ud835\udcc1","\ud835\udcc2","\ud835\udcc3","\u2134","\ud835\udcc5","\ud835\udcc6","\ud835\udcc7","\ud835\udcc8","\ud835\udcc9","\ud835\udcca","\ud835\udccb","\ud835\udccc","\ud835\udccd","\ud835\udcce","\ud835\udccf","\u2113"],A=["\ud835\udcd0","\ud835\udcd1","\ud835\udcd2","\ud835\udcd3","\ud835\udcd4","\ud835\udcd5","\ud835\udcd6","\ud835\udcd7","\ud835\udcd8","\ud835\udcd9","\ud835\udcda","\ud835\udcdb","\ud835\udcdc","\ud835\udcdd","\ud835\udcde","\ud835\udcdf","\ud835\udce0","\ud835\udce1","\ud835\udce2","\ud835\udce3","\ud835\udce4","\ud835\udce5","\ud835\udce6","\ud835\udce7","\ud835\udce8","\ud835\udce9"],C=["\ud835\udcea","\ud835\udceb","\ud835\udcec","\ud835\udced","\ud835\udcee","\ud835\udcef","\ud835\udcf0","\ud835\udcf1","\ud835\udcf2","\ud835\udcf3","\ud835\udcf4","\ud835\udcf5","\ud835\udcf6","\ud835\udcf7","\ud835\udcf8","\ud835\udcf9","\ud835\udcfa","\ud835\udcfb","\ud835\udcfc","\ud835\udcfd","\ud835\udcfe","\ud835\udcff","\ud835\udd00","\ud835\udd01","\ud835\udd02","\ud835\udd03"],T=["\ud835\udd04","\ud835\udd05","\u212d","\ud835\udd07","\ud835\udd08","\ud835\udd09","\ud835\udd0a","\u210c","\u2111","\ud835\udd0d","\ud835\udd0e","\ud835\udd0f","\ud835\udd10","\ud835\udd11","\ud835\udd12","\ud835\udd13","\ud835\udd14","\u211c","\ud835\udd16","\ud835\udd17","\ud835\udd18","\ud835\udd19","\ud835\udd1a","\ud835\udd1b","\ud835\udd1c","\u2128"],N=["\ud835\udd1e","\ud835\udd1f","\ud835\udd20","\ud835\udd21","\ud835\udd22","\ud835\udd23","\ud835\udd24","\ud835\udd25","\ud835\udd26","\ud835\udd27","\ud835\udd28","\ud835\udd29","\ud835\udd2a","\ud835\udd2b","\ud835\udd2c","\ud835\udd2d","\ud835\udd2e","\ud835\udd2f","\ud835\udd30","\ud835\udd31","\ud835\udd32","\ud835\udd33","\ud835\udd34","\ud835\udd35","\ud835\udd36","\ud835\udd37"],w=["\ud835\udd38","\ud835\udd39","\u2102","\ud835\udd3b","\ud835\udd3c","\ud835\udd3d","\ud835\udd3e","\u210d","\ud835\udd40","\ud835\udd41","\ud835\udd42","\ud835\udd43","\ud835\udd44","\u2115","\ud835\udd46","\u2119","\u211a","\u211d","\ud835\udd4a","\ud835\udd4b","\ud835\udd4c","\ud835\udd4d","\ud835\udd4e","\ud835\udd4f","\ud835\udd50","\u2124"],L=["\ud835\udd52","\ud835\udd53","\ud835\udd54","\ud835\udd55","\ud835\udd56","\ud835\udd57","\ud835\udd58","\ud835\udd59","\ud835\udd5a","\ud835\udd5b","\ud835\udd5c","\ud835\udd5d","\ud835\udd5e","\ud835\udd5f","\ud835\udd60","\ud835\udd61","\ud835\udd62","\ud835\udd63","\ud835\udd64","\ud835\udd65","\ud835\udd66","\ud835\udd67","\ud835\udd68","\ud835\udd69","\ud835\udd6a","\ud835\udd6b"],I=["\ud835\udd6c","\ud835\udd6d","\ud835\udd6e","\ud835\udd6f","\ud835\udd70","\ud835\udd71","\ud835\udd72","\ud835\udd73","\ud835\udd74","\ud835\udd75","\ud835\udd76","\ud835\udd77","\ud835\udd78","\ud835\udd79","\ud835\udd7a","\ud835\udd7b","\ud835\udd7c","\ud835\udd7d","\ud835\udd7e","\ud835\udd7f","\ud835\udd80","\ud835\udd81","\ud835\udd82","\ud835\udd83","\ud835\udd84","\ud835\udd85"],P=["\ud835\udd86","\ud835\udd87","\ud835\udd88","\ud835\udd89","\ud835\udd8a","\ud835\udd8b","\ud835\udd8c","\ud835\udd8d","\ud835\udd8e","\ud835\udd8f","\ud835\udd90","\ud835\udd91","\ud835\udd92","\ud835\udd93","\ud835\udd94","\ud835\udd95","\ud835\udd96","\ud835\udd97","\ud835\udd98","\ud835\udd99","\ud835\udd9a","\ud835\udd9b","\ud835\udd9c","\ud835\udd9d","\ud835\udd9e","\ud835\udd9f"],R=["\ud835\udda0","\ud835\udda1","\ud835\udda2","\ud835\udda3","\ud835\udda4","\ud835\udda5","\ud835\udda6","\ud835\udda7","\ud835\udda8","\ud835\udda9","\ud835\uddaa","\ud835\uddab","\ud835\uddac","\ud835\uddad","\ud835\uddae","\ud835\uddaf","\ud835\uddb0","\ud835\uddb1","\ud835\uddb2","\ud835\uddb3","\ud835\uddb4","\ud835\uddb5","\ud835\uddb6","\ud835\uddb7","\ud835\uddb8","\ud835\uddb9"],k=["\ud835\uddba","\ud835\uddbb","\ud835\uddbc","\ud835\uddbd","\ud835\uddbe","\ud835\uddbf","\ud835\uddc0","\ud835\uddc1","\ud835\uddc2","\ud835\uddc3","\ud835\uddc4","\ud835\uddc5","\ud835\uddc6","\ud835\uddc7","\ud835\uddc8","\ud835\uddc9","\ud835\uddca","\ud835\uddcb","\ud835\uddcc","\ud835\uddcd","\ud835\uddce","\ud835\uddcf","\ud835\uddd0","\ud835\uddd1","\ud835\uddd2","\ud835\uddd3"],j=["\ud835\uddd4","\ud835\uddd5","\ud835\uddd6","\ud835\uddd7","\ud835\uddd8","\ud835\uddd9","\ud835\uddda","\ud835\udddb","\ud835\udddc","\ud835\udddd","\ud835\uddde","\ud835\udddf","\ud835\udde0","\ud835\udde1","\ud835\udde2","\ud835\udde3","\ud835\udde4","\ud835\udde5","\ud835\udde6","\ud835\udde7","\ud835\udde8","\ud835\udde9","\ud835\uddea","\ud835\uddeb","\ud835\uddec","\ud835\udded"],B=["\ud835\uddee","\ud835\uddef","\ud835\uddf0","\ud835\uddf1","\ud835\uddf2","\ud835\uddf3","\ud835\uddf4","\ud835\uddf5","\ud835\uddf6","\ud835\uddf7","\ud835\uddf8","\ud835\uddf9","\ud835\uddfa","\ud835\uddfb","\ud835\uddfc","\ud835\uddfd","\ud835\uddfe","\ud835\uddff","\ud835\ude00","\ud835\ude01","\ud835\ude02","\ud835\ude03","\ud835\ude04","\ud835\ude05","\ud835\ude06","\ud835\ude07"],D=["\ud835\ude08","\ud835\ude09","\ud835\ude0a","\ud835\ude0b","\ud835\ude0c","\ud835\ude0d","\ud835\ude0e","\ud835\ude0f","\ud835\ude10","\ud835\ude11","\ud835\ude12","\ud835\ude13","\ud835\ude14","\ud835\ude15","\ud835\ude16","\ud835\ude17","\ud835\ude18","\ud835\ude19","\ud835\ude1a","\ud835\ude1b","\ud835\ude1c","\ud835\ude1d","\ud835\ude1e","\ud835\ude1f","\ud835\ude20","\ud835\ude21"],F=["\ud835\ude22","\ud835\ude23","\ud835\ude24","\ud835\ude25","\ud835\ude26","\ud835\ude27","\ud835\ude28","\ud835\ude29","\ud835\ude2a","\ud835\ude2b","\ud835\ude2c","\ud835\ude2d","\ud835\ude2e","\ud835\ude2f","\ud835\ude30","\ud835\ude31","\ud835\ude32","\ud835\ude33","\ud835\ude34","\ud835\ude35","\ud835\ude36","\ud835\ude37","\ud835\ude38","\ud835\ude39","\ud835\ude3a","\ud835\ude3b"],H=["\ud835\ude3c","\ud835\ude3d","\ud835\ude3e","\ud835\ude3f","\ud835\ude40","\ud835\ude41","\ud835\ude42","\ud835\ude43","\ud835\ude44","\ud835\ude45","\ud835\ude46","\ud835\ude47","\ud835\ude48","\ud835\ude49","\ud835\ude4a","\ud835\ude4b","\ud835\ude4c","\ud835\ude4d","\ud835\ude4e","\ud835\ude4f","\ud835\ude50","\ud835\ude51","\ud835\ude52","\ud835\ude53","\ud835\ude54","\ud835\ude55"],U=["\ud835\ude56","\ud835\ude57","\ud835\ude58","\ud835\ude59","\ud835\ude5a","\ud835\ude5b","\ud835\ude5c","\ud835\ude5d","\ud835\ude5e","\ud835\ude5f","\ud835\ude60","\ud835\ude61","\ud835\ude62","\ud835\ude63","\ud835\ude64","\ud835\ude65","\ud835\ude66","\ud835\ude67","\ud835\ude68","\ud835\ude69","\ud835\ude6a","\ud835\ude6b","\ud835\ude6c","\ud835\ude6d","\ud835\ude6e","\ud835\ude6f"],X=["\ud835\ude70","\ud835\ude71","\ud835\ude72","\ud835\ude73","\ud835\ude74","\ud835\ude75","\ud835\ude76","\ud835\ude77","\ud835\ude78","\ud835\ude79","\ud835\ude7a","\ud835\ude7b","\ud835\ude7c","\ud835\ude7d","\ud835\ude7e","\ud835\ude7f","\ud835\ude80","\ud835\ude81","\ud835\ude82","\ud835\ude83","\ud835\ude84","\ud835\ude85","\ud835\ude86","\ud835\ude87","\ud835\ude88","\ud835\ude89"],V=["\ud835\ude8a","\ud835\ude8b","\ud835\ude8c","\ud835\ude8d","\ud835\ude8e","\ud835\ude8f","\ud835\ude90","\ud835\ude91","\ud835\ude92","\ud835\ude93","\ud835\ude94","\ud835\ude95","\ud835\ude96","\ud835\ude97","\ud835\ude98","\ud835\ude99","\ud835\ude9a","\ud835\ude9b","\ud835\ude9c","\ud835\ude9d","\ud835\ude9e","\ud835\ude9f","\ud835\udea0","\ud835\udea1","\ud835\udea2","\ud835\udea3"],q=["\u2145","\u2146","\u2147","\u2148","\u2149"],W=["\u0391","\u0392","\u0393","\u0394","\u0395","\u0396","\u0397","\u0398","\u0399","\u039a","\u039b","\u039c","\u039d","\u039e","\u039f","\u03a0","\u03a1","\u03a3","\u03a4","\u03a5","\u03a6","\u03a7","\u03a8","\u03a9"],G=["\u03b1","\u03b2","\u03b3","\u03b4","\u03b5","\u03b6","\u03b7","\u03b8","\u03b9","\u03ba","\u03bb","\u03bc","\u03bd","\u03be","\u03bf","\u03c0","\u03c1","\u03c2","\u03c3","\u03c4","\u03c5","\u03c6","\u03c7","\u03c8","\u03c9"],z=["\ud835\udea8","\ud835\udea9","\ud835\udeaa","\ud835\udeab","\ud835\udeac","\ud835\udead","\ud835\udeae","\ud835\udeaf","\ud835\udeb0","\ud835\udeb1","\ud835\udeb2","\ud835\udeb3","\ud835\udeb4","\ud835\udeb5","\ud835\udeb6","\ud835\udeb7","\ud835\udeb8","\ud835\udeba","\ud835\udebb","\ud835\udebc","\ud835\udebd","\ud835\udebe","\ud835\udebf","\ud835\udec0"],J=["\ud835\udec2","\ud835\udec3","\ud835\udec4","\ud835\udec5","\ud835\udec6","\ud835\udec7","\ud835\udec8","\ud835\udec9","\ud835\udeca","\ud835\udecb","\ud835\udecc","\ud835\udecd","\ud835\udece","\ud835\udecf","\ud835\uded0","\ud835\uded1","\ud835\uded2","\ud835\uded3","\ud835\uded4","\ud835\uded5","\ud835\uded6","\ud835\uded7","\ud835\uded8","\ud835\uded9","\ud835\udeda"],K=["\ud835\udee2","\ud835\udee3","\ud835\udee4","\ud835\udee5","\ud835\udee6","\ud835\udee7","\ud835\udee8","\ud835\udee9","\ud835\udeea","\ud835\udeeb","\ud835\udeec","\ud835\udeed","\ud835\udeee","\ud835\udeef","\ud835\udef0","\ud835\udef1","\ud835\udef2","\ud835\udef4","\ud835\udef5","\ud835\udef6","\ud835\udef7","\ud835\udef8","\ud835\udef9","\ud835\udefa"],$=["\ud835\udefc","\ud835\udefd","\ud835\udefe","\ud835\udeff","\ud835\udf00","\ud835\udf01","\ud835\udf02","\ud835\udf03","\ud835\udf04","\ud835\udf05","\ud835\udf06","\ud835\udf07","\ud835\udf08","\ud835\udf09","\ud835\udf0a","\ud835\udf0b","\ud835\udf0c","\ud835\udf0d","\ud835\udf0e","\ud835\udf0f","\ud835\udf10","\ud835\udf11","\ud835\udf12","\ud835\udf13","\ud835\udf14"],Y=["\ud835\udf1c","\ud835\udf1d","\ud835\udf1e","\ud835\udf1f","\ud835\udf20","\ud835\udf21","\ud835\udf22","\ud835\udf23","\ud835\udf24","\ud835\udf25","\ud835\udf26","\ud835\udf27","\ud835\udf28","\ud835\udf29","\ud835\udf2a","\ud835\udf2b","\ud835\udf2c","\ud835\udf2e","\ud835\udf2f","\ud835\udf30","\ud835\udf31","\ud835\udf32","\ud835\udf33","\ud835\udf34"],Z=["\ud835\udf36","\ud835\udf37","\ud835\udf38","\ud835\udf39","\ud835\udf3a","\ud835\udf3b","\ud835\udf3c","\ud835\udf3d","\ud835\udf3e","\ud835\udf3f","\ud835\udf40","\ud835\udf41","\ud835\udf42","\ud835\udf43","\ud835\udf44","\ud835\udf45","\ud835\udf46","\ud835\udf47","\ud835\udf48","\ud835\udf49","\ud835\udf4a","\ud835\udf4b","\ud835\udf4c","\ud835\udf4d","\ud835\udf4e"],Q=["\ud835\udf56","\ud835\udf57","\ud835\udf58","\ud835\udf59","\ud835\udf5a","\ud835\udf5b","\ud835\udf5c","\ud835\udf5d","\ud835\udf5e","\ud835\udf5f","\ud835\udf60","\ud835\udf61","\ud835\udf62","\ud835\udf63","\ud835\udf64","\ud835\udf65","\ud835\udf66","\ud835\udf68","\ud835\udf69","\ud835\udf6a","\ud835\udf6b","\ud835\udf6c","\ud835\udf6d","\ud835\udf6e"],tt=["\ud835\udf70","\ud835\udf71","\ud835\udf72","\ud835\udf73","\ud835\udf74","\ud835\udf75","\ud835\udf76","\ud835\udf77","\ud835\udf78","\ud835\udf79","\ud835\udf7a","\ud835\udf7b","\ud835\udf7c","\ud835\udf7d","\ud835\udf7e","\ud835\udf7f","\ud835\udf80","\ud835\udf81","\ud835\udf82","\ud835\udf83","\ud835\udf84","\ud835\udf85","\ud835\udf86","\ud835\udf87","\ud835\udf88"],et=["\ud835\udf90","\ud835\udf91","\ud835\udf92","\ud835\udf93","\ud835\udf94","\ud835\udf95","\ud835\udf96","\ud835\udf97","\ud835\udf98","\ud835\udf99","\ud835\udf9a","\ud835\udf9b","\ud835\udf9c","\ud835\udf9d","\ud835\udf9e","\ud835\udf9f","\ud835\udfa0","\ud835\udfa2","\ud835\udfa3","\ud835\udfa4","\ud835\udfa5","\ud835\udfa6","\ud835\udfa7","\ud835\udfa8"],rt=["\ud835\udfaa","\ud835\udfab","\ud835\udfac","\ud835\udfad","\ud835\udfae","\ud835\udfaf","\ud835\udfb0","\ud835\udfb1","\ud835\udfb2","\ud835\udfb3","\ud835\udfb4","\ud835\udfb5","\ud835\udfb6","\ud835\udfb7","\ud835\udfb8","\ud835\udfb9","\ud835\udfba","\ud835\udfbb","\ud835\udfbc","\ud835\udfbd","\ud835\udfbe","\ud835\udfbf","\ud835\udfc0","\ud835\udfc1","\ud835\udfc2"],nt=["\u213c","\u213d","\u213e","\u213f"],ot=["\u03d0","\u03d1","\u03d5","\u03d6","\u03d7","\u03f0","\u03f1","\u03f5","\u03f6","\u03f4"],it=["\ud835\udedc","\ud835\udedd","\ud835\udede","\ud835\udedf","\ud835\udee0","\ud835\udee1"],st=["\ud835\udf16","\ud835\udf17","\ud835\udf18","\ud835\udf19","\ud835\udf1a","\ud835\udf1b"],at=["\ud835\udf8a","\ud835\udf8b","\ud835\udf8c","\ud835\udf8d","\ud835\udf8e","\ud835\udf8f"],lt=["\u2135","\u2136","\u2137","\u2138"],ct=d.concat(m,y,g,b,v,_,M,O,S,x,E,A,C,T,N,w,L,I,P,R,k,j,B,D,F,H,U,X,V,q,W,G,z,J,K,$,Y,Z,Q,tt,nt,ot,et,rt,it,st,at,lt);e.allLettersRegExp=new RegExp(ct.join("|"));const ut=["+","\xb1","\u2213","\u2214","\u2227","\u2228","\u2229","\u222a","\u228c","\u228d","\u228e","\u2293","\u2294","\u229d","\u229e","\u22a4","\u22a5","\u22ba","\u22bb","\u22bc","\u22c4","\u22ce","\u22cf","\u22d2","\u22d3","\u2a5e","\u2295","\u22d4"],pt=String.fromCodePoint(8292);ut.push(pt);const ht=["\u2020","\u2021","\u2210","\u2217","\u2218","\u2219","\u2240","\u229a","\u229b","\u22a0","\u22a1","\u22c5","\u22c6","\u22c7","\u22c8","\u22c9","\u22ca","\u22cb","\u22cc","\u25cb","\xb7","*","\u2297","\u2299"],ft=String.fromCodePoint(8290);ht.push(ft);const dt=String.fromCodePoint(8289),mt=["\xbc","\xbd","\xbe","\u2150","\u2151","\u2152","\u2153","\u2154","\u2155","\u2156","\u2157","\u2158","\u2159","\u215a","\u215b","\u215c","\u215d","\u215e","\u215f","\u2189"],yt=["\xb2","\xb3","\xb9","\u2070","\u2074","\u2075","\u2076","\u2077","\u2078","\u2079"].concat(["\u2080","\u2081","\u2082","\u2083","\u2084","\u2085","\u2086","\u2087","\u2088","\u2089"],["\u2460","\u2461","\u2462","\u2463","\u2464","\u2465","\u2466","\u2467","\u2468","\u2469","\u246a","\u246b","\u246c","\u246d","\u246e","\u246f","\u2470","\u2471","\u2472","\u2473","\u24ea","\u24eb","\u24ec","\u24ed","\u24ee","\u24ef","\u24f0","\u24f1","\u24f2","\u24f3","\u24f4","\u24f5","\u24f6","\u24f7","\u24f8","\u24f9","\u24fa","\u24fb","\u24fc","\u24fd","\u24fe","\u24ff","\u2776","\u2777","\u2778","\u2779","\u277a","\u277b","\u277c","\u277d","\u277e","\u277f","\u2780","\u2781","\u2782","\u2783","\u2784","\u2785","\u2786","\u2787","\u2788","\u2789","\u278a","\u278b","\u278c","\u278d","\u278e","\u278f","\u2790","\u2791","\u2792","\u2793","\u3248","\u3249","\u324a","\u324b","\u324c","\u324d","\u324e","\u324f","\u3251","\u3252","\u3253","\u3254","\u3255","\u3256","\u3257","\u3258","\u3259","\u325a","\u325b","\u325c","\u325d","\u325e","\u325f","\u32b1","\u32b2","\u32b3","\u32b4","\u32b5","\u32b6","\u32b7","\u32b8","\u32b9","\u32ba","\u32bb","\u32bc","\u32bd","\u32be","\u32bf"],["\u2474","\u2475","\u2476","\u2477","\u2478","\u2479","\u247a","\u247b","\u247c","\u247d","\u247e","\u247f","\u2480","\u2481","\u2482","\u2483","\u2484","\u2485","\u2486","\u2487"],["\u2488","\u2489","\u248a","\u248b","\u248c","\u248d","\u248e","\u248f","\u2490","\u2491","\u2492","\u2493","\u2494","\u2495","\u2496","\u2497","\u2498","\u2499","\u249a","\u249b","\ud83c\udd00","\ud83c\udd01","\ud83c\udd02","\ud83c\udd03","\ud83c\udd04","\ud83c\udd05","\ud83c\udd06","\ud83c\udd07","\ud83c\udd08","\ud83c\udd09","\ud83c\udd0a"]),gt=["cos","cot","csc","sec","sin","tan","arccos","arccot","arccsc","arcsec","arcsin","arctan","arc cos","arc cot","arc csc","arc sec","arc sin","arc tan"].concat(["cosh","coth","csch","sech","sinh","tanh","arcosh","arcoth","arcsch","arsech","arsinh","artanh","arccosh","arccoth","arccsch","arcsech","arcsinh","arctanh"],["deg","det","dim","hom","ker","Tr","tr"],["log","ln","lg","exp","expt","gcd","gcd","arg","im","re","Pr"]),bt=[{set:["!",'"',"#","%","&",";","?","@","\\","\xa1","\xa7","\xb6","\xbf","\u2017","\u2020","\u2021","\u2022","\u2023","\u2024","\u2025","\u2027","\u2030","\u2031","\u2038","\u203b","\u203c","\u203d","\u203e","\u2041","\u2042","\u2043","\u2047","\u2048","\u2049","\u204b","\u204c","\u204d","\u204e","\u204f","\u2050","\u2051","\u2053","\u2055","\u2056","\u2058","\u2059","\u205a","\u205b","\u205c","\u205d","\u205e","\ufe10","\ufe14","\ufe15","\ufe16","\ufe30","\ufe45","\ufe46","\ufe49","\ufe4a","\ufe4b","\ufe4c","\ufe54","\ufe56","\ufe57","\ufe5f","\ufe60","\ufe61","\ufe68","\ufe6a","\ufe6b","\uff01","\uff02","\uff03","\uff05","\uff06","\uff07","\uff0a","\uff0f","\uff1b","\uff1f","\uff20","\uff3c"],type:"punctuation",role:"unknown"},{set:["\ufe13",":","\uff1a","\ufe55"],type:"punctuation",role:"colon"},{set:n,type:"punctuation",role:"comma"},{set:["\u2026","\u22ee","\u22ef","\u22f0","\u22f1","\ufe19"],type:"punctuation",role:"ellipsis"},{set:[".","\ufe52","\uff0e"],type:"punctuation",role:"fullstop"},{set:o,type:"operator",role:"dash"},{set:i,type:"operator",role:"tilde"},{set:["'","\u2032","\u2033","\u2034","\u2035","\u2036","\u2037","\u2057","\u02b9","\u02ba"],type:"punctuation",role:"prime"},{set:["\xb0"],type:"punctuation",role:"degree"},{set:l,type:"fence",role:"open"},{set:c,type:"fence",role:"close"},{set:u,type:"fence",role:"top"},{set:p,type:"fence",role:"bottom"},{set:h,type:"fence",role:"neutral"},{set:f,type:"fence",role:"metric"},{set:m,type:"identifier",role:"latinletter",font:"normal"},{set:d,type:"identifier",role:"latinletter",font:"normal"},{set:g,type:"identifier",role:"latinletter",font:"normal"},{set:y,type:"identifier",role:"latinletter",font:"normal"},{set:v,type:"identifier",role:"latinletter",font:"bold"},{set:b,type:"identifier",role:"latinletter",font:"bold"},{set:S,type:"identifier",role:"latinletter",font:"italic"},{set:_,type:"identifier",role:"latinletter",font:"italic"},{set:O,type:"identifier",role:"latinletter",font:"bold-italic"},{set:M,type:"identifier",role:"latinletter",font:"bold-italic"},{set:E,type:"identifier",role:"latinletter",font:"script"},{set:x,type:"identifier",role:"latinletter",font:"script"},{set:C,type:"identifier",role:"latinletter",font:"bold-script"},{set:A,type:"identifier",role:"latinletter",font:"bold-script"},{set:N,type:"identifier",role:"latinletter",font:"fraktur"},{set:T,type:"identifier",role:"latinletter",font:"fraktur"},{set:L,type:"identifier",role:"latinletter",font:"double-struck"},{set:w,type:"identifier",role:"latinletter",font:"double-struck"},{set:P,type:"identifier",role:"latinletter",font:"bold-fraktur"},{set:I,type:"identifier",role:"latinletter",font:"bold-fraktur"},{set:k,type:"identifier",role:"latinletter",font:"sans-serif"},{set:R,type:"identifier",role:"latinletter",font:"sans-serif"},{set:B,type:"identifier",role:"latinletter",font:"sans-serif-bold"},{set:j,type:"identifier",role:"latinletter",font:"sans-serif-bold"},{set:F,type:"identifier",role:"latinletter",font:"sans-serif-italic"},{set:D,type:"identifier",role:"latinletter",font:"sans-serif-italic"},{set:U,type:"identifier",role:"latinletter",font:"sans-serif-bold-italic"},{set:H,type:"identifier",role:"latinletter",font:"sans-serif-bold-italic"},{set:V,type:"identifier",role:"latinletter",font:"monospace"},{set:X,type:"identifier",role:"latinletter",font:"monospace"},{set:q,type:"identifier",role:"latinletter",font:"double-struck-italic"},{set:G,type:"identifier",role:"greekletter",font:"normal"},{set:W,type:"identifier",role:"greekletter",font:"normal"},{set:J,type:"identifier",role:"greekletter",font:"bold"},{set:z,type:"identifier",role:"greekletter",font:"bold"},{set:$,type:"identifier",role:"greekletter",font:"italic"},{set:K,type:"identifier",role:"greekletter",font:"italic"},{set:Z,type:"identifier",role:"greekletter",font:"bold-italic"},{set:Y,type:"identifier",role:"greekletter",font:"bold-italic"},{set:tt,type:"identifier",role:"greekletter",font:"sans-serif-bold"},{set:Q,type:"identifier",role:"greekletter",font:"sans-serif-bold"},{set:et,type:"identifier",role:"greekletter",font:"sans-serif-bold-italic"},{set:rt,type:"identifier",role:"greekletter",font:"sans-serif-bold-italic"},{set:nt,type:"identifier",role:"greekletter",font:"double-struck"},{set:ot,type:"identifier",role:"greekletter",font:"normal"},{set:it,type:"identifier",role:"greekletter",font:"bold"},{set:st,type:"identifier",role:"greekletter",font:"italic"},{set:at,type:"identifier",role:"greekletter",font:"sans-serif-bold"},{set:lt,type:"identifier",role:"otherletter",font:"normal"},{set:["0","1","2","3","4","5","6","7","8","9"],type:"number",role:"integer",font:"normal"},{set:["\uff10","\uff11","\uff12","\uff13","\uff14","\uff15","\uff16","\uff17","\uff18","\uff19"],type:"number",role:"integer",font:"normal"},{set:["\ud835\udfce","\ud835\udfcf","\ud835\udfd0","\ud835\udfd1","\ud835\udfd2","\ud835\udfd3","\ud835\udfd4","\ud835\udfd5","\ud835\udfd6","\ud835\udfd7"],type:"number",role:"integer",font:"bold"},{set:["\ud835\udfd8","\ud835\udfd9","\ud835\udfda","\ud835\udfdb","\ud835\udfdc","\ud835\udfdd","\ud835\udfde","\ud835\udfdf","\ud835\udfe0","\ud835\udfe1"],type:"number",role:"integer",font:"double-struck"},{set:["\ud835\udfe2","\ud835\udfe3","\ud835\udfe4","\ud835\udfe5","\ud835\udfe6","\ud835\udfe7","\ud835\udfe8","\ud835\udfe9","\ud835\udfea","\ud835\udfeb"],type:"number",role:"integer",font:"sans-serif"},{set:["\ud835\udfec","\ud835\udfed","\ud835\udfee","\ud835\udfef","\ud835\udff0","\ud835\udff1","\ud835\udff2","\ud835\udff3","\ud835\udff4","\ud835\udff5"],type:"number",role:"integer",font:"sans-serif-bold"},{set:["\ud835\udff6","\ud835\udff7","\ud835\udff8","\ud835\udff9","\ud835\udffa","\ud835\udffb","\ud835\udffc","\ud835\udffd","\ud835\udffe","\ud835\udfff"],type:"number",role:"integer",font:"monospace"},{set:mt,type:"number",role:"float"},{set:yt,type:"number",role:"othernumber"},{set:ut,type:"operator",role:"addition"},{set:ht,type:"operator",role:"multiplication"},{set:["\xaf","-","\u2052","\u207b","\u208b","\u2212","\u2216","\u2238","\u2242","\u2296","\u229f","\u2796","\u2a29","\u2a2a","\u2a2b","\u2a2c","\u2a3a","\u2a41","\ufe63","\uff0d","\u2010","\u2011"],type:"operator",role:"subtraction"},{set:["/","\xf7","\u2044","\u2215","\u2298","\u27cc","\u29bc","\u2a38"],type:"operator",role:"division"},{set:["\u2200","\u2203","\u2206","\u2207","\u2202","\u2201","\u2204"],type:"operator",role:"prefix operator"},{set:["\ud835\udec1","\ud835\udedb","\ud835\udfca","\ud835\udfcb"],type:"operator",role:"prefix operator",font:"bold"},{set:["\ud835\udefb","\ud835\udf15"],type:"operator",role:"prefix operator",font:"italic"},{set:["\ud835\udf6f","\ud835\udf89"],type:"operator",role:"prefix operator",font:"sans-serif-bold"},{set:["=","~","\u207c","\u208c","\u223c","\u223d","\u2243","\u2245","\u2248","\u224a","\u224b","\u224c","\u224d","\u224e","\u2251","\u2252","\u2253","\u2254","\u2255","\u2256","\u2257","\u2258","\u2259","\u225a","\u225b","\u225c","\u225d","\u225e","\u225f","\u2261","\u2263","\u29e4","\u2a66","\u2a6e","\u2a6f","\u2a70","\u2a71","\u2a72","\u2a73","\u2a74","\u2a75","\u2a76","\u2a77","\u2a78","\u22d5","\u2a6d","\u2a6a","\u2a6b","\u2a6c","\ufe66","\uff1d","\u2a6c","\u229c","\u2237"],type:"relation",role:"equality"},{set:["<",">","\u2241","\u2242","\u2244","\u2246","\u2247","\u2249","\u224f","\u2250","\u2260","\u2262","\u2264","\u2265","\u2266","\u2267","\u2268","\u2269","\u226a","\u226b","\u226c","\u226d","\u226e","\u226f","\u2270","\u2271","\u2272","\u2273","\u2274","\u2275","\u2276","\u2277","\u2278","\u2279","\u227a","\u227b","\u227c","\u227d","\u227e","\u227f","\u2280","\u2281","\u22d6","\u22d7","\u22d8","\u22d9","\u22da","\u22db","\u22dc","\u22dd","\u22de","\u22df","\u22e0","\u22e1","\u22e6","\u22e7","\u22e8","\u22e9","\u2a79","\u2a7a","\u2a7b","\u2a7c","\u2a7d","\u2a7e","\u2a7f","\u2a80","\u2a81","\u2a82","\u2a83","\u2a84","\u2a85","\u2a86","\u2a87","\u2a88","\u2a89","\u2a8a","\u2a8b","\u2a8c","\u2a8d","\u2a8e","\u2a8f","\u2a90","\u2a91","\u2a92","\u2a93","\u2a94","\u2a95","\u2a96","\u2a97","\u2a98","\u2a99","\u2a9a","\u2a9b","\u2a9c","\u2a9d","\u2a9e","\u2a9f","\u2aa0","\u2aa1","\u2aa2","\u2aa3","\u2aa4","\u2aa5","\u2aa6","\u2aa7","\u2aa8","\u2aa9","\u2aaa","\u2aab","\u2aac","\u2aad","\u2aae","\u2aaf","\u2ab0","\u2ab1","\u2ab2","\u2ab3","\u2ab4","\u2ab5","\u2ab6","\u2ab7","\u2ab8","\u2ab9","\u2aba","\u2abb","\u2abc","\u2af7","\u2af8","\u2af9","\u2afa","\u29c0","\u29c1","\ufe64","\ufe65","\uff1c","\uff1e"],type:"relation",role:"inequality"},{set:["\u22e2","\u22e3","\u22e4","\u22e5","\u2282","\u2283","\u2284","\u2285","\u2286","\u2287","\u2288","\u2289","\u228a","\u228b","\u228f","\u2290","\u2291","\u2292","\u2abd","\u2abe","\u2abf","\u2ac0","\u2ac1","\u2ac2","\u2ac3","\u2ac4","\u2ac5","\u2ac6","\u2ac7","\u2ac8","\u2ac9","\u2aca","\u2acb","\u2acc","\u2acd","\u2ace","\u2acf","\u2ad0","\u2ad1","\u2ad2","\u2ad3","\u2ad4","\u2ad5","\u2ad6","\u2ad7","\u2ad8","\u22d0","\u22d1","\u22ea","\u22eb","\u22ec","\u22ed","\u22b2","\u22b3","\u22b4","\u22b5"],type:"relation",role:"set"},{set:["\u22a2","\u22a3","\u22a6","\u22a7","\u22a8","\u22a9","\u22aa","\u22ab","\u22ac","\u22ad","\u22ae","\u22af","\u2ade","\u2adf","\u2ae0","\u2ae1","\u2ae2","\u2ae3","\u2ae4","\u2ae5","\u2ae6","\u2ae7","\u2ae8","\u2ae9","\u2aea","\u2aeb","\u2aec","\u2aed"],type:"relation",role:"unknown"},{set:["\u2190","\u2191","\u2192","\u2193","\u2194","\u2195","\u2196","\u2197","\u2198","\u2199","\u219a","\u219b","\u219c","\u219d","\u219e","\u219f","\u21a0","\u21a1","\u21a2","\u21a3","\u21a4","\u21a5","\u21a6","\u21a7","\u21a8","\u21a9","\u21aa","\u21ab","\u21ac","\u21ad","\u21ae","\u21af","\u21b0","\u21b1","\u21b2","\u21b3","\u21b4","\u21b5","\u21b6","\u21b7","\u21b8","\u21b9","\u21ba","\u21bb","\u21c4","\u21c5","\u21c6","\u21c7","\u21c8","\u21c9","\u21ca","\u21cd","\u21ce","\u21cf","\u21d0","\u21d1","\u21d2","\u21d3","\u21d4","\u21d5","\u21d6","\u21d7","\u21d8","\u21d9","\u21da","\u21db","\u21dc","\u21dd","\u21de","\u21df","\u21e0","\u21e1","\u21e2","\u21e3","\u21e4","\u21e5","\u21e6","\u21e7","\u21e8","\u21e9","\u21ea","\u21eb","\u21ec","\u21ed","\u21ee","\u21ef","\u21f0","\u21f1","\u21f2","\u21f3","\u21f4","\u21f5","\u21f6","\u21f7","\u21f8","\u21f9","\u21fa","\u21fb","\u21fc","\u21fd","\u21fe","\u21ff","\u2301","\u2303","\u2304","\u2324","\u238b","\u2794","\u2798","\u2799","\u279a","\u279b","\u279c","\u279d","\u279e","\u279f","\u27a0","\u27a1","\u27a2","\u27a3","\u27a4","\u27a5","\u27a6","\u27a7","\u27a8","\u27a9","\u27aa","\u27ab","\u27ac","\u27ad","\u27ae","\u27af","\u27b1","\u27b2","\u27b3","\u27b4","\u27b5","\u27b6","\u27b7","\u27b8","\u27b9","\u27ba","\u27bb","\u27bc","\u27bd","\u27be","\u27f0","\u27f1","\u27f2","\u27f3","\u27f4","\u27f5","\u27f6","\u27f7","\u27f8","\u27f9","\u27fa","\u27fb","\u27fc","\u27fd","\u27fe","\u27ff","\u2900","\u2901","\u2902","\u2903","\u2904","\u2905","\u2906","\u2907","\u2908","\u2909","\u290a","\u290b","\u290c","\u290d","\u290e","\u290f","\u2910","\u2911","\u2912","\u2913","\u2914","\u2915","\u2916","\u2917","\u2918","\u2919","\u291a","\u291b","\u291c","\u291d","\u291e","\u291f","\u2920","\u2921","\u2922","\u2923","\u2924","\u2925","\u2926","\u2927","\u2928","\u2929","\u292a","\u292d","\u292e","\u292f","\u2930","\u2931","\u2932","\u2933","\u2934","\u2935","\u2936","\u2937","\u2938","\u2939","\u293a","\u293b","\u293c","\u293d","\u293e","\u293f","\u2940","\u2941","\u2942","\u2943","\u2944","\u2945","\u2946","\u2947","\u2948","\u2949","\u2970","\u2971","\u2972","\u2973","\u2974","\u2975","\u2976","\u2977","\u2978","\u2979","\u297a","\u297b","\u29b3","\u29b4","\u29bd","\u29ea","\u29ec","\u29ed","\u2a17","\u2b00","\u2b01","\u2b02","\u2b03","\u2b04","\u2b05","\u2b06","\u2b07","\u2b08","\u2b09","\u2b0a","\u2b0b","\u2b0c","\u2b0d","\u2b0e","\u2b0f","\u2b10","\u2b11","\u2b30","\u2b31","\u2b32","\u2b33","\u2b34","\u2b35","\u2b36","\u2b37","\u2b38","\u2b39","\u2b3a","\u2b3b","\u2b3c","\u2b3d","\u2b3e","\u2b3f","\u2b40","\u2b41","\u2b42","\u2b43","\u2b44","\u2b45","\u2b46","\u2b47","\u2b48","\u2b49","\u2b4a","\u2b4b","\u2b4c","\uffe9","\uffea","\uffeb","\uffec","\u21bc","\u21bd","\u21be","\u21bf","\u21c0","\u21c1","\u21c2","\u21c3","\u21cb","\u21cc","\u294a","\u294b","\u294c","\u294d","\u294e","\u294f","\u2950","\u2951","\u2952","\u2953","\u2954","\u2955","\u2956","\u2957","\u2958","\u2959","\u295a","\u295b","\u295c","\u295d","\u295e","\u295f","\u2960","\u2961","\u2962","\u2963","\u2964","\u2965","\u2966","\u2967","\u2968","\u2969","\u296a","\u296b","\u296c","\u296d","\u296e","\u296f","\u297c","\u297d","\u297e","\u297f"],type:"relation",role:"arrow"},{set:["\u2208","\u220a","\u22f2","\u22f3","\u22f4","\u22f5","\u22f6","\u22f7","\u22f8","\u22f9","\u22ff"],type:"operator",role:"element"},{set:["\u2209"],type:"operator",role:"nonelement"},{set:["\u220b","\u220d","\u22fa","\u22fb","\u22fc","\u22fd","\u22fe"],type:"operator",role:"reelement"},{set:["\u220c"],type:"operator",role:"renonelement"},{set:["\u2140","\u220f","\u2210","\u2211","\u22c0","\u22c1","\u22c2","\u22c3","\u2a00","\u2a01","\u2a02","\u2a03","\u2a04","\u2a05","\u2a06","\u2a07","\u2a08","\u2a09","\u2a0a","\u2a0b","\u2afc","\u2aff"],type:"largeop",role:"sum"},{set:["\u222b","\u222c","\u222d","\u222e","\u222f","\u2230","\u2231","\u2232","\u2233","\u2a0c","\u2a0d","\u2a0e","\u2a0f","\u2a10","\u2a11","\u2a12","\u2a13","\u2a14","\u2a15","\u2a16","\u2a17","\u2a18","\u2a19","\u2a1a","\u2a1b","\u2a1c"],type:"largeop",role:"integral"},{set:["\u221f","\u2220","\u2221","\u2222","\u22be","\u22bf","\u25b3","\u25b7","\u25bd","\u25c1"],type:"operator",role:"geometry"},{set:["inf","lim","liminf","limsup","max","min","sup","injlim","projlim","inj lim","proj lim"],type:"function",role:"limit function"},{set:gt,type:"function",role:"prefix function"},{set:["mod","rem"],type:"operator",role:"prefix function"}],vt=function(){const t={};for(let e,r=0;e=bt[r];r++)e.set.forEach((function(r){t[r]={role:e.role||"unknown",type:e.type||"unknown",font:e.font||"unknown"}}));return t}();e.equal=function(t,e){return t.type===e.type&&t.role===e.role&&t.font===e.font},e.lookupType=function(t){var e;return(null===(e=vt[t])||void 0===e?void 0:e.type)||"unknown"},e.lookupRole=function(t){var e;return(null===(e=vt[t])||void 0===e?void 0:e.role)||"unknown"},e.lookupMeaning=function(t){return vt[t]||{role:"unknown",type:"unknown",font:"unknown"}},e.invisibleTimes=function(){return ft},e.invisiblePlus=function(){return pt},e.invisibleComma=function(){return r},e.functionApplication=function(){return dt},e.isMatchingFence=function(t,e){return-1!==h.indexOf(t)||-1!==f.indexOf(t)?t===e:s[t]===e||a[t]===e},e.isEmbellishedType=function(t){return"operator"===t||"relation"===t||"fence"===t||"punctuation"===t};const _t=new Map;function St(t,e){return`${t} ${e}`}function Mt(t,e,r=""){for(const n of e)_t.set(St(t,n),r||t)}Mt("d",["d","\u2146","\uff44","\ud835\udc1d","\ud835\udc51","\ud835\udcb9","\ud835\udced","\ud835\udd21","\ud835\udd55","\ud835\udd89","\ud835\uddbd","\ud835\uddf1","\ud835\ude25","\ud835\ude8d"]),Mt("bar",o),Mt("tilde",i),e.lookupSecondary=function(t,e){return _t.get(St(t,e))}},8158:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticMeaningCollator=e.SemanticNodeCollator=e.SemanticDefault=void 0;const n=r(3588),o=r(3882);class i{constructor(){this.map={}}static key(t,e){return e?t+":"+e:t}add(t,e){this.map[i.key(t,e.font)]=e}addNode(t){this.add(t.textContent,t.meaning())}retrieve(t,e){return this.map[i.key(t,e)]}retrieveNode(t){return this.retrieve(t.textContent,t.font)}size(){return Object.keys(this.map).length}}e.SemanticDefault=i;class s{constructor(){this.map={}}add(t,e){const r=this.map[t];r?r.push(e):this.map[t]=[e]}retrieve(t,e){return this.map[i.key(t,e)]}retrieveNode(t){return this.retrieve(t.textContent,t.font)}copy(){const t=this.copyCollator();for(const e in this.map)t.map[e]=this.map[e];return t}minimize(){for(const t in this.map)1===this.map[t].length&&delete this.map[t]}minimalCollator(){const t=this.copy();for(const e in t.map)1===t.map[e].length&&delete t.map[e];return t}isMultiValued(){for(const t in this.map)if(this.map[t].length>1)return!0;return!1}isEmpty(){return!Object.keys(this.map).length}}class a extends s{copyCollator(){return new a}add(t,e){const r=i.key(t,e.font);super.add(r,e)}addNode(t){this.add(t.textContent,t)}toString(){const t=[];for(const e in this.map){const r=Array(e.length+3).join(" "),n=this.map[e],o=[];for(let t,e=0;t=n[e];e++)o.push(t.toString());t.push(e+": "+o.join("\n"+r))}return t.join("\n")}collateMeaning(){const t=new l;for(const e in this.map)t.map[e]=this.map[e].map((function(t){return t.meaning()}));return t}}e.SemanticNodeCollator=a;class l extends s{copyCollator(){return new l}add(t,e){const r=this.retrieve(t,e.font);if(!r||!r.find((function(t){return n.equal(t,e)}))){const r=i.key(t,e.font);super.add(r,e)}}addNode(t){this.add(t.textContent,t.meaning())}toString(){const t=[];for(const e in this.map){const r=Array(e.length+3).join(" "),n=this.map[e],o=[];for(let t,e=0;t=n[e];e++)o.push("{type: "+t.type+", role: "+t.role+", font: "+t.font+"}");t.push(e+": "+o.join("\n"+r))}return t.join("\n")}reduce(){for(const t in this.map)1!==this.map[t].length&&(this.map[t]=(0,o.reduce)(this.map[t]))}default(){const t=new i;for(const e in this.map)1===this.map[e].length&&(t.map[e]=this.map[e][0]);return t}newDefault(){const t=this.default();this.reduce();const e=this.default();return t.size()!==e.size()?e:null}}e.SemanticMeaningCollator=l},9911:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticMultiHeuristic=e.SemanticTreeHeuristic=e.SemanticAbstractHeuristic=void 0;class r{constructor(t,e,r=(t=>!1)){this.name=t,this.apply=e,this.applicable=r}}e.SemanticAbstractHeuristic=r;e.SemanticTreeHeuristic=class extends r{};e.SemanticMultiHeuristic=class extends r{}},7516:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.lookup=e.run=e.add=e.blacklist=e.flags=e.updateFactory=e.factory=void 0,e.factory=null,e.updateFactory=function(t){e.factory=t};const r=new Map;function n(t){return r.get(t)}e.flags={combine_juxtaposition:!0,convert_juxtaposition:!0,multioperator:!0},e.blacklist={},e.add=function(t){const n=t.name;r.set(n,t),e.flags[n]||(e.flags[n]=!1)},e.run=function(t,r,o){const i=n(t);return i&&!e.blacklist[t]&&(e.flags[t]||i.applicable(r))?i.apply(r):o?o(r):r},e.lookup=n},94:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(2057),o=r(5897),i=r(3588),s=r(7516),a=r(9911),l=r(5609),c=r(3308),u=r(4795);function p(t,e,r){let n=null;if(!t.length)return n;const o=r[r.length-1],i=o&&o.length,s=e&&e.length,a=c.default.getInstance();if(i&&s){if("infixop"===e[0].type&&"implicit"===e[0].role)return n=t.pop(),o.push(a.postfixNode_(o.pop(),t)),n;n=t.shift();const r=a.prefixNode_(e.shift(),t);return e.unshift(r),n}return i?(o.push(a.postfixNode_(o.pop(),t)),n):(s&&e.unshift(a.prefixNode_(e.shift(),t)),n)}function h(t,e,r){if(!e.length)return t;const o=t.pop(),i=e.shift(),a=r.shift();if(l.isImplicitOp(i)){n.Debugger.getInstance().output("Juxta Heuristic Case 2");const s=(o?[o,i]:[i]).concat(a);return h(t.concat(s),e,r)}if(!o)return n.Debugger.getInstance().output("Juxta Heuristic Case 3"),h([i].concat(a),e,r);const c=a.shift();if(!c){n.Debugger.getInstance().output("Juxta Heuristic Case 9");const a=s.factory.makeBranchNode("infixop",[o,e.shift()],[i],i.textContent);return a.role="implicit",s.run("combine_juxtaposition",a),e.unshift(a),h(t,e,r)}if(l.isOperator(o)||l.isOperator(c))return n.Debugger.getInstance().output("Juxta Heuristic Case 4"),h(t.concat([o,i,c]).concat(a),e,r);let u=null;return l.isImplicitOp(o)&&l.isImplicitOp(c)?(n.Debugger.getInstance().output("Juxta Heuristic Case 5"),o.contentNodes.push(i),o.contentNodes=o.contentNodes.concat(c.contentNodes),o.childNodes.push(c),o.childNodes=o.childNodes.concat(c.childNodes),c.childNodes.forEach((t=>t.parent=o)),i.parent=o,o.addMathmlNodes(i.mathml),o.addMathmlNodes(c.mathml),u=o):l.isImplicitOp(o)?(n.Debugger.getInstance().output("Juxta Heuristic Case 6"),o.contentNodes.push(i),o.childNodes.push(c),c.parent=o,i.parent=o,o.addMathmlNodes(i.mathml),o.addMathmlNodes(c.mathml),u=o):l.isImplicitOp(c)?(n.Debugger.getInstance().output("Juxta Heuristic Case 7"),c.contentNodes.unshift(i),c.childNodes.unshift(o),o.parent=c,i.parent=c,c.addMathmlNodes(i.mathml),c.addMathmlNodes(o.mathml),u=c):(n.Debugger.getInstance().output("Juxta Heuristic Case 8"),u=s.factory.makeBranchNode("infixop",[o,c],[i],i.textContent),u.role="implicit"),t.push(u),h(t.concat(a),e,r)}s.add(new a.SemanticTreeHeuristic("combine_juxtaposition",(function(t){for(let e,r=t.childNodes.length-1;e=t.childNodes[r];r--)l.isImplicitOp(e)&&!e.nobreaking&&(t.childNodes.splice(r,1,...e.childNodes),t.contentNodes.splice(r,0,...e.contentNodes),e.childNodes.concat(e.contentNodes).forEach((function(e){e.parent=t})),t.addMathmlNodes(e.mathml));return t}))),s.add(new a.SemanticTreeHeuristic("propagateSimpleFunction",(t=>("infixop"!==t.type&&"fraction"!==t.type||!t.childNodes.every(l.isSimpleFunction)||(t.role="composed function"),t)),(t=>"clearspeak"===o.default.getInstance().domain))),s.add(new a.SemanticTreeHeuristic("simpleNamedFunction",(t=>("unit"!==t.role&&-1!==["f","g","h","F","G","H"].indexOf(t.textContent)&&(t.role="simple function"),t)),(t=>"clearspeak"===o.default.getInstance().domain))),s.add(new a.SemanticTreeHeuristic("propagateComposedFunction",(t=>("fenced"===t.type&&"composed function"===t.childNodes[0].role&&(t.role="composed function"),t)),(t=>"clearspeak"===o.default.getInstance().domain))),s.add(new a.SemanticTreeHeuristic("multioperator",(t=>{if("unknown"!==t.role||t.textContent.length<=1)return;const e=[...t.textContent].map(i.lookupMeaning).reduce((function(t,e){return t&&e.role&&"unknown"!==e.role&&e.role!==t?"unknown"===t?e.role:null:t}),"unknown");e&&(t.role=e)}))),s.add(new a.SemanticMultiHeuristic("convert_juxtaposition",(t=>{let e=u.partitionNodes(t,(function(t){return t.textContent===i.invisibleTimes()&&"operator"===t.type}));e=e.rel.length?function(t){const e=[],r=[];let n=t.comp.shift(),o=null,i=[];for(;t.comp.length;)if(i=[],n.length)o&&e.push(o),r.push(n),o=t.rel.shift(),n=t.comp.shift();else{for(o&&i.push(o);!n.length&&t.comp.length;)n=t.comp.shift(),i.push(t.rel.shift());o=p(i,n,r)}i.length||n.length?(e.push(o),r.push(n)):(i.push(o),p(i,n,r));return{rel:e,comp:r}}(e):e,t=e.comp[0];for(let r,n,o=1;r=e.comp[o],n=e.rel[o-1];o++)t.push(n),t=t.concat(r);return e=u.partitionNodes(t,(function(t){return t.textContent===i.invisibleTimes()&&("operator"===t.type||"infixop"===t.type)})),e.rel.length?h(e.comp.shift(),e.rel,e.comp):t}))),s.add(new a.SemanticTreeHeuristic("simple2prefix",(t=>(t.textContent.length>1&&!t.textContent[0].match(/[A-Z]/)&&(t.role="prefix function"),t)),(t=>"braille"===o.default.getInstance().modality&&"identifier"===t.type))),s.add(new a.SemanticTreeHeuristic("detect_cycle",(t=>{t.type="matrix",t.role="cycle";const e=t.childNodes[0];return e.type="row",e.role="cycle",e.textContent="",e.contentNodes=[],t}),(t=>"fenced"===t.type&&"infixop"===t.childNodes[0].type&&"implicit"===t.childNodes[0].role&&t.childNodes[0].childNodes.every((function(t){return"number"===t.type}))&&t.childNodes[0].contentNodes.every((function(t){return"space"===t.role})))))},7228:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticMathml=void 0;const n=r(5740),o=r(5250),i=r(5609),s=r(3308),a=r(4795);class l extends o.SemanticAbstractParser{constructor(){super("MathML"),this.parseMap_={SEMANTICS:this.semantics_.bind(this),MATH:this.rows_.bind(this),MROW:this.rows_.bind(this),MPADDED:this.rows_.bind(this),MSTYLE:this.rows_.bind(this),MFRAC:this.fraction_.bind(this),MSUB:this.limits_.bind(this),MSUP:this.limits_.bind(this),MSUBSUP:this.limits_.bind(this),MOVER:this.limits_.bind(this),MUNDER:this.limits_.bind(this),MUNDEROVER:this.limits_.bind(this),MROOT:this.root_.bind(this),MSQRT:this.sqrt_.bind(this),MTABLE:this.table_.bind(this),MLABELEDTR:this.tableLabeledRow_.bind(this),MTR:this.tableRow_.bind(this),MTD:this.tableCell_.bind(this),MS:this.text_.bind(this),MTEXT:this.text_.bind(this),MSPACE:this.space_.bind(this),"ANNOTATION-XML":this.text_.bind(this),MI:this.identifier_.bind(this),MN:this.number_.bind(this),MO:this.operator_.bind(this),MFENCED:this.fenced_.bind(this),MENCLOSE:this.enclosed_.bind(this),MMULTISCRIPTS:this.multiscripts_.bind(this),ANNOTATION:this.empty_.bind(this),NONE:this.empty_.bind(this),MACTION:this.action_.bind(this)};const t={type:"identifier",role:"numbersetletter",font:"double-struck"};["C","H","N","P","Q","R","Z","\u2102","\u210d","\u2115","\u2119","\u211a","\u211d","\u2124"].forEach((e=>this.getFactory().defaultMap.add(e,t)).bind(this))}static getAttribute_(t,e,r){if(!t.hasAttribute(e))return r;const n=t.getAttribute(e);return n.match(/^\s*$/)?null:n}parse(t){s.default.getInstance().setNodeFactory(this.getFactory());const e=n.toArray(t.childNodes),r=n.tagName(t),o=this.parseMap_[r],i=(o||this.dummy_.bind(this))(t,e);return a.addAttributes(i,t),-1!==["MATH","MROW","MPADDED","MSTYLE","SEMANTICS"].indexOf(r)||(i.mathml.unshift(t),i.mathmlTree=t),i}semantics_(t,e){return e.length?this.parse(e[0]):this.getFactory().makeEmptyNode()}rows_(t,e){const r=t.getAttribute("semantics");if(r&&r.match("bspr_"))return s.default.proof(t,r,this.parseList.bind(this));let n;return 1===(e=a.purgeNodes(e)).length?(n=this.parse(e[0]),"empty"!==n.type||n.mathmlTree||(n.mathmlTree=t)):n=s.default.getInstance().row(this.parseList(e)),n.mathml.unshift(t),n}fraction_(t,e){if(!e.length)return this.getFactory().makeEmptyNode();const r=this.parse(e[0]),n=e[1]?this.parse(e[1]):this.getFactory().makeEmptyNode();return s.default.getInstance().fractionLikeNode(r,n,t.getAttribute("linethickness"),"true"===t.getAttribute("bevelled"))}limits_(t,e){return s.default.getInstance().limitNode(n.tagName(t),this.parseList(e))}root_(t,e){return e[1]?this.getFactory().makeBranchNode("root",[this.parse(e[1]),this.parse(e[0])],[]):this.sqrt_(t,e)}sqrt_(t,e){const r=this.parseList(a.purgeNodes(e));return this.getFactory().makeBranchNode("sqrt",[s.default.getInstance().row(r)],[])}table_(t,e){const r=t.getAttribute("semantics");if(r&&r.match("bspr_"))return s.default.proof(t,r,this.parseList.bind(this));const n=this.getFactory().makeBranchNode("table",this.parseList(e),[]);return n.mathmlTree=t,s.default.tableToMultiline(n),n}tableRow_(t,e){const r=this.getFactory().makeBranchNode("row",this.parseList(e),[]);return r.role="table",r}tableLabeledRow_(t,e){if(!e.length)return this.tableRow_(t,e);const r=this.parse(e[0]);r.role="label";const n=this.getFactory().makeBranchNode("row",this.parseList(e.slice(1)),[r]);return n.role="table",n}tableCell_(t,e){const r=this.parseList(a.purgeNodes(e));let n;n=r.length?1===r.length&&i.isType(r[0],"empty")?r:[s.default.getInstance().row(r)]:[];const o=this.getFactory().makeBranchNode("cell",n,[]);return o.role="table",o}space_(t,e){const r=t.getAttribute("width"),o=r&&r.match(/[a-z]*$/);if(!o)return this.empty_(t,e);const i=o[0],a=parseFloat(r.slice(0,o.index)),l={cm:.4,pc:.5,em:.5,ex:1,in:.15,pt:5,mm:5}[i];if(!l||isNaN(a)||a1?this.parse(e[1]):this.getFactory().makeUnprocessed(t)}dummy_(t,e){const r=this.getFactory().makeUnprocessed(t);return r.role=t.tagName,r.textContent=t.textContent,r}leaf_(t,e){if(1===e.length&&e[0].nodeType!==n.NodeType.TEXT_NODE){const r=this.getFactory().makeUnprocessed(t);return r.role=e[0].tagName,a.addAttributes(r,e[0]),r}return this.getFactory().makeLeafNode(t.textContent,s.default.getInstance().font(t.getAttribute("mathvariant")))}}e.SemanticMathml=l},5952:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticNode=void 0;const n=r(5740),o=r(3588),i=r(4795);class s{constructor(t){this.id=t,this.mathml=[],this.parent=null,this.type="unknown",this.role="unknown",this.font="unknown",this.embellished=null,this.fencePointer="",this.childNodes=[],this.textContent="",this.mathmlTree=null,this.contentNodes=[],this.annotation={},this.attributes={},this.nobreaking=!1}static fromXml(t){const e=parseInt(t.getAttribute("id"),10),r=new s(e);return r.type=t.tagName,s.setAttribute(r,t,"role"),s.setAttribute(r,t,"font"),s.setAttribute(r,t,"embellished"),s.setAttribute(r,t,"fencepointer","fencePointer"),t.getAttribute("annotation")&&r.parseAnnotation(t.getAttribute("annotation")),i.addAttributes(r,t),s.processChildren(r,t),r}static setAttribute(t,e,r,n){n=n||r;const o=e.getAttribute(r);o&&(t[n]=o)}static processChildren(t,e){for(const r of n.toArray(e.childNodes)){if(r.nodeType===n.NodeType.TEXT_NODE){t.textContent=r.textContent;continue}const e=n.toArray(r.childNodes).map(s.fromXml);e.forEach((e=>e.parent=t)),"CONTENT"===n.tagName(r)?t.contentNodes=e:t.childNodes=e}}querySelectorAll(t){let e=[];for(let r,n=0;r=this.childNodes[n];n++)e=e.concat(r.querySelectorAll(t));for(let r,n=0;r=this.contentNodes[n];n++)e=e.concat(r.querySelectorAll(t));return t(this)&&e.unshift(this),e}xml(t,e){const r=function(r,n){const o=n.map((function(r){return r.xml(t,e)})),i=t.createElementNS("",r);for(let t,e=0;t=o[e];e++)i.appendChild(t);return i},n=t.createElementNS("",this.type);return e||this.xmlAttributes(n),n.textContent=this.textContent,this.contentNodes.length>0&&n.appendChild(r("content",this.contentNodes)),this.childNodes.length>0&&n.appendChild(r("children",this.childNodes)),n}toString(t=!1){const e=n.parseInput("");return n.serializeXml(this.xml(e,t))}allAttributes(){const t=[];return t.push(["role",this.role]),"unknown"!==this.font&&t.push(["font",this.font]),Object.keys(this.annotation).length&&t.push(["annotation",this.xmlAnnotation()]),this.embellished&&t.push(["embellished",this.embellished]),this.fencePointer&&t.push(["fencepointer",this.fencePointer]),t.push(["id",this.id.toString()]),t}xmlAnnotation(){const t=[];for(const e in this.annotation)this.annotation[e].forEach((function(r){t.push(e+":"+r)}));return t.join(";")}toJson(){const t={};t.type=this.type;const e=this.allAttributes();for(let r,n=0;r=e[n];n++)t[r[0]]=r[1].toString();return this.textContent&&(t.$t=this.textContent),this.childNodes.length&&(t.children=this.childNodes.map((function(t){return t.toJson()}))),this.contentNodes.length&&(t.content=this.contentNodes.map((function(t){return t.toJson()}))),t}updateContent(t,e){const r=e?t.replace(/^[ \f\n\r\t\v\u200b]*/,"").replace(/[ \f\n\r\t\v\u200b]*$/,""):t.trim();if(t=t&&!r?t:r,this.textContent===t)return;const n=(0,o.lookupMeaning)(t);this.textContent=t,this.role=n.role,this.type=n.type,this.font=n.font}addMathmlNodes(t){for(let e,r=0;e=t[r];r++)-1===this.mathml.indexOf(e)&&this.mathml.push(e)}appendChild(t){this.childNodes.push(t),this.addMathmlNodes(t.mathml),t.parent=this}replaceChild(t,e){const r=this.childNodes.indexOf(t);if(-1===r)return;t.parent=null,e.parent=this,this.childNodes[r]=e;const n=t.mathml.filter((function(t){return-1===e.mathml.indexOf(t)})),o=e.mathml.filter((function(e){return-1===t.mathml.indexOf(e)}));this.removeMathmlNodes(n),this.addMathmlNodes(o)}appendContentNode(t){t&&(this.contentNodes.push(t),this.addMathmlNodes(t.mathml),t.parent=this)}removeContentNode(t){if(t){const e=this.contentNodes.indexOf(t);-1!==e&&this.contentNodes.slice(e,1)}}equals(t){if(!t)return!1;if(this.type!==t.type||this.role!==t.role||this.textContent!==t.textContent||this.childNodes.length!==t.childNodes.length||this.contentNodes.length!==t.contentNodes.length)return!1;for(let e,r,n=0;e=this.childNodes[n],r=t.childNodes[n];n++)if(!e.equals(r))return!1;for(let e,r,n=0;e=this.contentNodes[n],r=t.contentNodes[n];n++)if(!e.equals(r))return!1;return!0}displayTree(){console.info(this.displayTree_(0))}addAnnotation(t,e){e&&this.addAnnotation_(t,e)}getAnnotation(t){const e=this.annotation[t];return e||[]}hasAnnotation(t,e){const r=this.annotation[t];return!!r&&-1!==r.indexOf(e)}parseAnnotation(t){const e=t.split(";");for(let t=0,r=e.length;t1)return!1;const r=e[0];if("infixop"===r.type){if("implicit"!==r.role)return!1;if(r.childNodes.some((t=>i(t,"infixop"))))return!1}return!0},e.isPrefixFunctionBoundary=function(t){return c(t)&&!a(t,"division")||i(t,"appl")||l(t)},e.isBigOpBoundary=function(t){return c(t)||l(t)},e.isIntegralDxBoundary=function(t,e){return!!e&&i(e,"identifier")&&n.lookupSecondary("d",t.textContent)},e.isIntegralDxBoundarySingle=function(t){if(i(t,"identifier")){const e=t.textContent[0];return e&&t.textContent[1]&&n.lookupSecondary("d",e)}return!1},e.isGeneralFunctionBoundary=l,e.isEmbellished=function(t){return t.embellished?t.embellished:n.isEmbellishedType(t.type)?t.type:null},e.isOperator=c,e.isRelation=u,e.isPunctuation=p,e.isFence=h,e.isElligibleEmbellishedFence=function(t){return!(!t||!h(t))&&(!t.embellished||f(t))},e.isTableOrMultiline=d,e.tableIsMatrixOrVector=function(t){return!!t&&m(t)&&d(t.childNodes[0])},e.isFencedElement=m,e.tableIsCases=function(t,e){return e.length>0&&a(e[e.length-1],"openfence")},e.tableIsMultiline=function(t){return t.childNodes.every((function(t){return t.childNodes.length<=1}))},e.lineIsLabelled=function(t){return i(t,"line")&&t.contentNodes.length&&a(t.contentNodes[0],"label")},e.isBinomial=function(t){return 2===t.childNodes.length},e.isLimitBase=function t(e){return i(e,"largeop")||i(e,"limboth")||i(e,"limlower")||i(e,"limupper")||i(e,"function")&&a(e,"limit function")||(i(e,"overscore")||i(e,"underscore"))&&t(e.childNodes[0])},e.isSimpleFunctionHead=function(t){return"identifier"===t.type||"latinletter"===t.role||"greekletter"===t.role||"otherletter"===t.role},e.singlePunctAtPosition=function(t,e,r){return 1===e.length&&("punctuation"===t[r].type||"punctuation"===t[r].embellished)&&t[r]===e[0]},e.isSimpleFunction=function(t){return i(t,"identifier")&&a(t,"simple function")},e.isLeftBrace=y,e.isRightBrace=g,e.isSetNode=function(t){return y(t.contentNodes[0])&&g(t.contentNodes[1])},e.illegalSingleton_=["punctuation","punctuated","relseq","multirel","table","multiline","cases","inference"],e.scriptedElement_=["limupper","limlower","limboth","subscript","superscript","underscore","overscore","tensor"],e.isSingletonSetContent=function t(r){const n=r.type;return-1===e.illegalSingleton_.indexOf(n)&&("infixop"!==n||"implicit"===r.role)&&("fenced"===n?"leftright"!==r.role||t(r.childNodes[0]):-1===e.scriptedElement_.indexOf(n)||t(r.childNodes[0]))},e.isNumber=b,e.isUnitCounter=function(t){return b(t)||"vulgar"===t.role||"mixed"===t.role},e.isPureUnit=function(t){const e=t.childNodes;return"unit"===t.role&&(!e.length||"unit"===e[0].role)},e.isImplicit=function(t){return"implicit"===t.role||"unit"===t.role&&!!t.contentNodes.length&&t.contentNodes[0].textContent===n.invisibleTimes()},e.isImplicitOp=function(t){return"infixop"===t.type&&"implicit"===t.role},e.isNeutralFence=v,e.compareNeutralFences=function(t,e){return v(t)&&v(e)&&(0,o.getEmbellishedInner)(t).textContent===(0,o.getEmbellishedInner)(e).textContent},e.elligibleLeftNeutral=function(t){return!!v(t)&&(!t.embellished||"superscript"!==t.type&&"subscript"!==t.type&&("tensor"!==t.type||"empty"===t.childNodes[3].type&&"empty"===t.childNodes[4].type))},e.elligibleRightNeutral=function(t){return!!v(t)&&(!t.embellished||("tensor"!==t.type||"empty"===t.childNodes[1].type&&"empty"===t.childNodes[2].type))},e.isMembership=function(t){return["element","nonelement","reelement","renonelement"].includes(t.role)}},3308:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0});const n=r(5740),o=r(3588),i=r(7516),s=r(6537),a=r(5609),l=r(4795);class c{constructor(){this.funcAppls={},this.factory_=new s.SemanticNodeFactory,i.updateFactory(this.factory_)}static getInstance(){return c.instance=c.instance||new c,c.instance}static tableToMultiline(t){if(a.tableIsMultiline(t)){t.type="multiline";for(let e,r=0;e=t.childNodes[r];r++)c.rowToLine_(e,"multiline");1===t.childNodes.length&&!a.lineIsLabelled(t.childNodes[0])&&a.isFencedElement(t.childNodes[0].childNodes[0])&&c.tableToMatrixOrVector_(c.rewriteFencedLine_(t)),c.binomialForm_(t),c.classifyMultiline(t)}else c.classifyTable(t)}static number(t){"unknown"!==t.type&&"identifier"!==t.type||(t.type="number"),c.numberRole_(t),c.exprFont_(t)}static classifyMultiline(t){let e=0;const r=t.childNodes.length;let n;for(;e=r)return;const o=n.childNodes[0].role;"unknown"!==o&&t.childNodes.every((function(t){const e=t.childNodes[0];return!e||e.role===o&&(a.isType(e,"relation")||a.isType(e,"relseq"))}))&&(t.role=o)}static classifyTable(t){const e=c.computeColumns_(t);c.classifyByColumns_(t,e,"equality")||c.classifyByColumns_(t,e,"inequality",["equality"])||c.classifyByColumns_(t,e,"arrow")||c.detectCaleyTable(t)}static detectCaleyTable(t){if(!t.mathmlTree)return!1;const e=t.mathmlTree,r=e.getAttribute("columnlines"),n=e.getAttribute("rowlines");return!(!r||!n)&&(!(!c.cayleySpacing(r)||!c.cayleySpacing(n))&&(t.role="cayley",!0))}static cayleySpacing(t){const e=t.split(" ");return("solid"===e[0]||"dashed"===e[0])&&e.slice(1).every((t=>"none"===t))}static proof(t,e,r){const n=c.separateSemantics(e);return c.getInstance().proof(t,n,r)}static findSemantics(t,e,r){const n=null==r?null:r,o=c.getSemantics(t);return!!o&&(!!o[e]&&(null==n||o[e]===n))}static getSemantics(t){const e=t.getAttribute("semantics");return e?c.separateSemantics(e):null}static removePrefix(t){const[,...e]=t.split("_");return e.join("_")}static separateSemantics(t){const e={};return t.split(";").forEach((function(t){const[r,n]=t.split(":");e[c.removePrefix(r)]=n})),e}static matchSpaces_(t,e){for(let r,n=0;r=e[n];n++){const e=t[n].mathmlTree,o=t[n+1].mathmlTree;if(!e||!o)continue;const i=e.nextSibling;if(!i||i===o)continue;const s=c.getSpacer_(i);s&&(r.mathml.push(s),r.mathmlTree=s,r.role="space")}}static getSpacer_(t){if("MSPACE"===n.tagName(t))return t;for(;l.hasEmptyTag(t)&&1===t.childNodes.length;)if(t=t.childNodes[0],"MSPACE"===n.tagName(t))return t;return null}static fenceToPunct_(t){const e=c.FENCE_TO_PUNCT_[t.role];if(e){for(;t.embellished;)t.embellished="punctuation",a.isRole(t,"subsup")||a.isRole(t,"underover")||(t.role=e),t=t.childNodes[0];t.type="punctuation",t.role=e}}static classifyFunction_(t,e){if("appl"===t.type||"bigop"===t.type||"integral"===t.type)return"";if(e[0]&&e[0].textContent===o.functionApplication()){c.getInstance().funcAppls[t.id]=e.shift();let r="simple function";return i.run("simple2prefix",t),"prefix function"!==t.role&&"limit function"!==t.role||(r=t.role),c.propagateFunctionRole_(t,r),"prefix"}const r=c.CLASSIFY_FUNCTION_[t.role];return r||(a.isSimpleFunctionHead(t)?"simple":"")}static propagateFunctionRole_(t,e){if(t){if("infixop"===t.type)return;a.isRole(t,"subsup")||a.isRole(t,"underover")||(t.role=e),c.propagateFunctionRole_(t.childNodes[0],e)}}static getFunctionOp_(t,e){if(e(t))return t;for(let r,n=0;r=t.childNodes[n];n++){const t=c.getFunctionOp_(r,e);if(t)return t}return null}static tableToMatrixOrVector_(t){const e=t.childNodes[0];a.isType(e,"multiline")?c.tableToVector_(t):c.tableToMatrix_(t),t.contentNodes.forEach(e.appendContentNode.bind(e));for(let t,r=0;t=e.childNodes[r];r++)c.assignRoleToRow_(t,c.getComponentRoles_(e));return e.parent=null,e}static tableToVector_(t){const e=t.childNodes[0];e.type="vector",1!==e.childNodes.length?c.binomialForm_(e):c.tableToSquare_(t)}static binomialForm_(t){a.isBinomial(t)&&(t.role="binomial",t.childNodes[0].role="binomial",t.childNodes[1].role="binomial")}static tableToMatrix_(t){const e=t.childNodes[0];e.type="matrix",e.childNodes&&e.childNodes.length>0&&e.childNodes[0].childNodes&&e.childNodes.length===e.childNodes[0].childNodes.length?c.tableToSquare_(t):e.childNodes&&1===e.childNodes.length&&(e.role="rowvector")}static tableToSquare_(t){const e=t.childNodes[0];a.isNeutralFence(t)?e.role="determinant":e.role="squarematrix"}static getComponentRoles_(t){const e=t.role;return e&&"unknown"!==e?e:t.type.toLowerCase()||"unknown"}static tableToCases_(t,e){for(let e,r=0;e=t.childNodes[r];r++)c.assignRoleToRow_(e,"cases");return t.type="cases",t.appendContentNode(e),a.tableIsMultiline(t)&&c.binomialForm_(t),t}static rewriteFencedLine_(t){const e=t.childNodes[0],r=t.childNodes[0].childNodes[0],n=t.childNodes[0].childNodes[0].childNodes[0];return r.parent=t.parent,t.parent=r,n.parent=e,r.childNodes=[t],e.childNodes=[n],r}static rowToLine_(t,e){const r=e||"unknown";a.isType(t,"row")&&(t.type="line",t.role=r,1===t.childNodes.length&&a.isType(t.childNodes[0],"cell")&&(t.childNodes=t.childNodes[0].childNodes,t.childNodes.forEach((function(e){e.parent=t}))))}static assignRoleToRow_(t,e){a.isType(t,"line")?t.role=e:a.isType(t,"row")&&(t.role=e,t.childNodes.forEach((function(t){a.isType(t,"cell")&&(t.role=e)})))}static nextSeparatorFunction_(t){let e;if(t){if(t.match(/^\s+$/))return null;e=t.replace(/\s/g,"").split("").filter((function(t){return t}))}else e=[","];return function(){return e.length>1?e.shift():e[0]}}static numberRole_(t){if("unknown"!==t.role)return;const e=[...t.textContent].filter((t=>t.match(/[^\s]/))),r=e.map(o.lookupMeaning);if(r.every((function(t){return"number"===t.type&&"integer"===t.role||"punctuation"===t.type&&"comma"===t.role})))return t.role="integer",void("0"===e[0]&&t.addAnnotation("general","basenumber"));r.every((function(t){return"number"===t.type&&"integer"===t.role||"punctuation"===t.type}))?t.role="float":t.role="othernumber"}static exprFont_(t){if("unknown"!==t.font)return;const e=[...t.textContent].map(o.lookupMeaning).reduce((function(t,e){return t&&e.font&&"unknown"!==e.font&&e.font!==t?"unknown"===t?e.font:null:t}),"unknown");e&&(t.font=e)}static purgeFences_(t){const e=t.rel,r=t.comp,n=[],o=[];for(;e.length>0;){const t=e.shift();let i=r.shift();a.isElligibleEmbellishedFence(t)?(n.push(t),o.push(i)):(c.fenceToPunct_(t),i.push(t),i=i.concat(r.shift()),r.unshift(i))}return o.push(r.shift()),{rel:n,comp:o}}static rewriteFencedNode_(t){const e=t.contentNodes[0],r=t.contentNodes[1];let n=c.rewriteFence_(t,e);return t.contentNodes[0]=n.fence,n=c.rewriteFence_(n.node,r),t.contentNodes[1]=n.fence,t.contentNodes[0].parent=t,t.contentNodes[1].parent=t,n.node.parent=null,n.node}static rewriteFence_(t,e){if(!e.embellished)return{node:t,fence:e};const r=e.childNodes[0],n=c.rewriteFence_(t,r);return a.isType(e,"superscript")||a.isType(e,"subscript")||a.isType(e,"tensor")?(a.isRole(e,"subsup")||(e.role=t.role),r!==n.node&&(e.replaceChild(r,n.node),r.parent=t),c.propagateFencePointer_(e,r),{node:e,fence:n.fence}):(e.replaceChild(r,n.fence),e.mathmlTree&&-1===e.mathml.indexOf(e.mathmlTree)&&e.mathml.push(e.mathmlTree),{node:n.node,fence:e})}static propagateFencePointer_(t,e){t.fencePointer=e.fencePointer||e.id.toString(),t.embellished=null}static classifyByColumns_(t,e,r,n){return!!(3===e.length&&c.testColumns_(e,1,(t=>c.isPureRelation_(t,r)))||2===e.length&&(c.testColumns_(e,1,(t=>c.isEndRelation_(t,r)||c.isPureRelation_(t,r)))||c.testColumns_(e,0,(t=>c.isEndRelation_(t,r,!0)||c.isPureRelation_(t,r)))))&&(t.role=r,!0)}static isEndRelation_(t,e,r){const n=r?t.childNodes.length-1:0;return a.isType(t,"relseq")&&a.isRole(t,e)&&a.isType(t.childNodes[n],"empty")}static isPureRelation_(t,e){return a.isType(t,"relation")&&a.isRole(t,e)}static computeColumns_(t){const e=[];for(let r,n=0;r=t.childNodes[n];n++)for(let t,n=0;t=r.childNodes[n];n++){e[n]?e[n].push(t):e[n]=[t]}return e}static testColumns_(t,e,r){const n=t[e];return!!n&&(n.some((function(t){return t.childNodes.length&&r(t.childNodes[0])}))&&n.every((function(t){return!t.childNodes.length||r(t.childNodes[0])})))}setNodeFactory(t){c.getInstance().factory_=t,i.updateFactory(c.getInstance().factory_)}getNodeFactory(){return c.getInstance().factory_}identifierNode(t,e,r){if("MathML-Unit"===r)t.type="identifier",t.role="unit";else if(!e&&1===t.textContent.length&&("integer"===t.role||"latinletter"===t.role||"greekletter"===t.role)&&"normal"===t.font)return t.font="italic",i.run("simpleNamedFunction",t);return"unknown"===t.type&&(t.type="identifier"),c.exprFont_(t),i.run("simpleNamedFunction",t)}implicitNode(t){if(t=c.getInstance().getMixedNumbers_(t),1===(t=c.getInstance().combineUnits_(t)).length)return t[0];const e=c.getInstance().implicitNode_(t);return i.run("combine_juxtaposition",e)}text(t,e){return c.exprFont_(t),t.type="text","MS"===e?(t.role="string",t):"MSPACE"===e||t.textContent.match(/^\s*$/)?(t.role="space",t):t}row(t){return 0===(t=t.filter((function(t){return!a.isType(t,"empty")}))).length?c.getInstance().factory_.makeEmptyNode():(t=c.getInstance().getFencesInRow_(t),t=c.getInstance().tablesInRow(t),t=c.getInstance().getPunctuationInRow_(t),t=c.getInstance().getTextInRow_(t),t=c.getInstance().getFunctionsInRow_(t),c.getInstance().relationsInRow_(t))}limitNode(t,e){if(!e.length)return c.getInstance().factory_.makeEmptyNode();let r,n=e[0],o="unknown";if(!e[1])return n;if(a.isLimitBase(n)){r=c.MML_TO_LIMIT_[t];const i=r.length;if(o=r.type,e=e.slice(0,r.length+1),1===i&&a.isAccent(e[1])||2===i&&a.isAccent(e[1])&&a.isAccent(e[2]))return r=c.MML_TO_BOUNDS_[t],c.getInstance().accentNode_(n,e,r.type,r.length,r.accent);if(2===i){if(a.isAccent(e[1]))return n=c.getInstance().accentNode_(n,[n,e[1]],{MSUBSUP:"subscript",MUNDEROVER:"underscore"}[t],1,!0),e[2]?c.getInstance().makeLimitNode_(n,[n,e[2]],null,"limupper"):n;if(e[2]&&a.isAccent(e[2]))return n=c.getInstance().accentNode_(n,[n,e[2]],{MSUBSUP:"superscript",MUNDEROVER:"overscore"}[t],1,!0),c.getInstance().makeLimitNode_(n,[n,e[1]],null,"limlower");e[i]||(o="limlower")}return c.getInstance().makeLimitNode_(n,e,null,o)}return r=c.MML_TO_BOUNDS_[t],c.getInstance().accentNode_(n,e,r.type,r.length,r.accent)}tablesInRow(t){let e=l.partitionNodes(t,a.tableIsMatrixOrVector),r=[];for(let t,n=0;t=e.rel[n];n++)r=r.concat(e.comp.shift()),r.push(c.tableToMatrixOrVector_(t));r=r.concat(e.comp.shift()),e=l.partitionNodes(r,a.isTableOrMultiline),r=[];for(let t,n=0;t=e.rel[n];n++){const n=e.comp.shift();a.tableIsCases(t,n)&&c.tableToCases_(t,n.pop()),r=r.concat(n),r.push(t)}return r.concat(e.comp.shift())}mfenced(t,e,r,n){if(r&&n.length>0){const t=c.nextSeparatorFunction_(r),e=[n.shift()];n.forEach((r=>{e.push(c.getInstance().factory_.makeContentNode(t())),e.push(r)})),n=e}return t&&e?c.getInstance().horizontalFencedNode_(c.getInstance().factory_.makeContentNode(t),c.getInstance().factory_.makeContentNode(e),n):(t&&n.unshift(c.getInstance().factory_.makeContentNode(t)),e&&n.push(c.getInstance().factory_.makeContentNode(e)),c.getInstance().row(n))}fractionLikeNode(t,e,r,n){let o;if(!n&&l.isZeroLength(r)){const r=c.getInstance().factory_.makeBranchNode("line",[t],[]),n=c.getInstance().factory_.makeBranchNode("line",[e],[]);return o=c.getInstance().factory_.makeBranchNode("multiline",[r,n],[]),c.binomialForm_(o),c.classifyMultiline(o),o}return o=c.getInstance().fractionNode_(t,e),n&&o.addAnnotation("general","bevelled"),o}tensor(t,e,r,n,o){const i=c.getInstance().factory_.makeBranchNode("tensor",[t,c.getInstance().scriptNode_(e,"leftsub"),c.getInstance().scriptNode_(r,"leftsuper"),c.getInstance().scriptNode_(n,"rightsub"),c.getInstance().scriptNode_(o,"rightsuper")],[]);return i.role=t.role,i.embellished=a.isEmbellished(t),i}pseudoTensor(t,e,r){const n=t=>!a.isType(t,"empty"),o=e.filter(n).length,i=r.filter(n).length;if(!o&&!i)return t;const s=o?i?"MSUBSUP":"MSUB":"MSUP",l=[t];return o&&l.push(c.getInstance().scriptNode_(e,"rightsub",!0)),i&&l.push(c.getInstance().scriptNode_(r,"rightsuper",!0)),c.getInstance().limitNode(s,l)}font(t){const e=c.MATHJAX_FONTS[t];return e||t}proof(t,e,r){if(e.inference||e.axiom||console.log("Noise"),e.axiom){const e=c.getInstance().cleanInference(t.childNodes),n=e.length?c.getInstance().factory_.makeBranchNode("inference",r(e),[]):c.getInstance().factory_.makeEmptyNode();return n.role="axiom",n.mathmlTree=t,n}const n=c.getInstance().inference(t,e,r);return e.proof&&(n.role="proof",n.childNodes[0].role="final"),n}inference(t,e,r){if(e.inferenceRule){const e=c.getInstance().getFormulas(t,[],r);return c.getInstance().factory_.makeBranchNode("inference",[e.conclusion,e.premises],[])}const o=e.labelledRule,i=n.toArray(t.childNodes),s=[];"left"!==o&&"both"!==o||s.push(c.getInstance().getLabel(t,i,r,"left")),"right"!==o&&"both"!==o||s.push(c.getInstance().getLabel(t,i,r,"right"));const a=c.getInstance().getFormulas(t,i,r),l=c.getInstance().factory_.makeBranchNode("inference",[a.conclusion,a.premises],s);return l.mathmlTree=t,l}getLabel(t,e,r,o){const i=c.getInstance().findNestedRow(e,"prooflabel",o),s=c.getInstance().factory_.makeBranchNode("rulelabel",r(n.toArray(i.childNodes)),[]);return s.role=o,s.mathmlTree=i,s}getFormulas(t,e,r){const o=e.length?c.getInstance().findNestedRow(e,"inferenceRule"):t,i="up"===c.getSemantics(o).inferenceRule,s=i?o.childNodes[1]:o.childNodes[0],a=i?o.childNodes[0]:o.childNodes[1],l=s.childNodes[0].childNodes[0],u=n.toArray(l.childNodes[0].childNodes),p=[];let h=1;for(const t of u)h%2&&p.push(t.childNodes[0]),h++;const f=r(p),d=r(n.toArray(a.childNodes[0].childNodes))[0],m=c.getInstance().factory_.makeBranchNode("premises",f,[]);m.mathmlTree=l;const y=c.getInstance().factory_.makeBranchNode("conclusion",[d],[]);return y.mathmlTree=a.childNodes[0].childNodes[0],{conclusion:y,premises:m}}findNestedRow(t,e,r){return c.getInstance().findNestedRow_(t,e,0,r)}cleanInference(t){return n.toArray(t).filter((function(t){return"MSPACE"!==n.tagName(t)}))}operatorNode(t){return"unknown"===t.type&&(t.type="operator"),i.run("multioperator",t)}implicitNode_(t){const e=c.getInstance().factory_.makeMultipleContentNodes(t.length-1,o.invisibleTimes());c.matchSpaces_(t,e);const r=c.getInstance().infixNode_(t,e[0]);return r.role="implicit",e.forEach((function(t){t.parent=r})),r.contentNodes=e,r}infixNode_(t,e){const r=c.getInstance().factory_.makeBranchNode("infixop",t,[e],l.getEmbellishedInner(e).textContent);return r.role=e.role,i.run("propagateSimpleFunction",r)}explicitMixed_(t){const e=l.partitionNodes(t,(function(t){return t.textContent===o.invisiblePlus()}));if(!e.rel.length)return t;let r=[];for(let t,n=0;t=e.rel[n];n++){const o=e.comp[n],i=e.comp[n+1],s=o.length-1;if(o[s]&&i[0]&&a.isType(o[s],"number")&&!a.isRole(o[s],"mixed")&&a.isType(i[0],"fraction")){const t=c.getInstance().factory_.makeBranchNode("number",[o[s],i[0]],[]);t.role="mixed",r=r.concat(o.slice(0,s)),r.push(t),i.shift()}else r=r.concat(o),r.push(t)}return r.concat(e.comp[e.comp.length-1])}concatNode_(t,e,r){if(0===e.length)return t;const n=e.map((function(t){return l.getEmbellishedInner(t).textContent})).join(" "),o=c.getInstance().factory_.makeBranchNode(r,[t],e,n);return e.length>1&&(o.role="multiop"),o}prefixNode_(t,e){const r=l.partitionNodes(e,(t=>a.isRole(t,"subtraction")));let n=c.getInstance().concatNode_(t,r.comp.pop(),"prefixop");for(1===n.contentNodes.length&&"addition"===n.contentNodes[0].role&&"+"===n.contentNodes[0].textContent&&(n.role="positive");r.rel.length>0;)n=c.getInstance().concatNode_(n,[r.rel.pop()],"prefixop"),n.role="negative",n=c.getInstance().concatNode_(n,r.comp.pop(),"prefixop");return n}postfixNode_(t,e){return e.length?c.getInstance().concatNode_(t,e,"postfixop"):t}combineUnits_(t){const e=l.partitionNodes(t,(function(t){return!a.isRole(t,"unit")}));if(t.length===e.rel.length)return e.rel;const r=[];let n,o;do{const t=e.comp.shift();n=e.rel.shift();let i=null;o=r.pop(),o&&(t.length&&a.isUnitCounter(o)?t.unshift(o):r.push(o)),1===t.length&&(i=t.pop()),t.length>1&&(i=c.getInstance().implicitNode_(t),i.role="unit"),i&&r.push(i),n&&r.push(n)}while(n);return r}getMixedNumbers_(t){const e=l.partitionNodes(t,(function(t){return a.isType(t,"fraction")&&a.isRole(t,"vulgar")}));if(!e.rel.length)return t;let r=[];for(let t,n=0;t=e.rel[n];n++){const o=e.comp[n],i=o.length-1;if(o[i]&&a.isType(o[i],"number")&&(a.isRole(o[i],"integer")||a.isRole(o[i],"float"))){const e=c.getInstance().factory_.makeBranchNode("number",[o[i],t],[]);e.role="mixed",r=r.concat(o.slice(0,i)),r.push(e)}else r=r.concat(o),r.push(t)}return r.concat(e.comp[e.comp.length-1])}getTextInRow_(t){if(t.length<=1)return t;const e=l.partitionNodes(t,(t=>a.isType(t,"text")));if(0===e.rel.length)return t;const r=[];let n=e.comp[0];n.length>0&&r.push(c.getInstance().row(n));for(let t,o=0;t=e.rel[o];o++)r.push(t),n=e.comp[o+1],n.length>0&&r.push(c.getInstance().row(n));return[c.getInstance().dummyNode_(r)]}relationsInRow_(t){const e=l.partitionNodes(t,a.isRelation),r=e.rel[0];if(!r)return c.getInstance().operationsInRow_(t);if(1===t.length)return t[0];const n=e.comp.map(c.getInstance().operationsInRow_);let o;return e.rel.some((function(t){return!t.equals(r)}))?(o=c.getInstance().factory_.makeBranchNode("multirel",n,e.rel),e.rel.every((function(t){return t.role===r.role}))&&(o.role=r.role),o):(o=c.getInstance().factory_.makeBranchNode("relseq",n,e.rel,l.getEmbellishedInner(r).textContent),o.role=r.role,o)}operationsInRow_(t){if(0===t.length)return c.getInstance().factory_.makeEmptyNode();if(1===(t=c.getInstance().explicitMixed_(t)).length)return t[0];const e=[];for(;t.length>0&&a.isOperator(t[0]);)e.push(t.shift());if(0===t.length)return c.getInstance().prefixNode_(e.pop(),e);if(1===t.length)return c.getInstance().prefixNode_(t[0],e);t=i.run("convert_juxtaposition",t);const r=l.sliceNodes(t,a.isOperator),n=c.getInstance().prefixNode_(c.getInstance().implicitNode(r.head),e);return r.div?c.getInstance().operationsTree_(r.tail,n,r.div):n}operationsTree_(t,e,r,n){const o=n||[];if(0===t.length){if(o.unshift(r),"infixop"===e.type){const t=c.getInstance().postfixNode_(e.childNodes.pop(),o);return e.appendChild(t),e}return c.getInstance().postfixNode_(e,o)}const i=l.sliceNodes(t,a.isOperator);if(0===i.head.length)return o.push(i.div),c.getInstance().operationsTree_(i.tail,e,r,o);const s=c.getInstance().prefixNode_(c.getInstance().implicitNode(i.head),o),u=c.getInstance().appendOperand_(e,r,s);return i.div?c.getInstance().operationsTree_(i.tail,u,i.div,[]):u}appendOperand_(t,e,r){if("infixop"!==t.type)return c.getInstance().infixNode_([t,r],e);const n=c.getInstance().appendDivisionOp_(t,e,r);return n||(c.getInstance().appendExistingOperator_(t,e,r)?t:"multiplication"===e.role?c.getInstance().appendMultiplicativeOp_(t,e,r):c.getInstance().appendAdditiveOp_(t,e,r))}appendDivisionOp_(t,e,r){return"division"===e.role?a.isImplicit(t)?c.getInstance().infixNode_([t,r],e):c.getInstance().appendLastOperand_(t,e,r):"division"===t.role?c.getInstance().infixNode_([t,r],e):null}appendLastOperand_(t,e,r){let n=t,o=t.childNodes[t.childNodes.length-1];for(;o&&"infixop"===o.type&&!a.isImplicit(o);)n=o,o=n.childNodes[t.childNodes.length-1];const i=c.getInstance().infixNode_([n.childNodes.pop(),r],e);return n.appendChild(i),t}appendMultiplicativeOp_(t,e,r){if(a.isImplicit(t))return c.getInstance().infixNode_([t,r],e);let n=t,o=t.childNodes[t.childNodes.length-1];for(;o&&"infixop"===o.type&&!a.isImplicit(o);)n=o,o=n.childNodes[t.childNodes.length-1];const i=c.getInstance().infixNode_([n.childNodes.pop(),r],e);return n.appendChild(i),t}appendAdditiveOp_(t,e,r){return c.getInstance().infixNode_([t,r],e)}appendExistingOperator_(t,e,r){return!(!t||"infixop"!==t.type||a.isImplicit(t))&&(t.contentNodes[0].equals(e)?(t.appendContentNode(e),t.appendChild(r),!0):c.getInstance().appendExistingOperator_(t.childNodes[t.childNodes.length-1],e,r))}getFencesInRow_(t){let e=l.partitionNodes(t,a.isFence);e=c.purgeFences_(e);const r=e.comp.shift();return c.getInstance().fences_(e.rel,e.comp,[],[r])}fences_(t,e,r,n){if(0===t.length&&0===r.length)return n[0];const o=t=>a.isRole(t,"open");if(0===t.length){const t=n.shift();for(;r.length>0;){if(o(r[0])){const e=r.shift();c.fenceToPunct_(e),t.push(e)}else{const e=l.sliceNodes(r,o),i=e.head.length-1,s=c.getInstance().neutralFences_(e.head,n.slice(0,i));n=n.slice(i),t.push(...s),e.div&&e.tail.unshift(e.div),r=e.tail}t.push(...n.shift())}return t}const i=r[r.length-1],s=t[0].role;if("open"===s||a.isNeutralFence(t[0])&&(!i||!a.compareNeutralFences(t[0],i))){r.push(t.shift());const o=e.shift();return o&&n.push(o),c.getInstance().fences_(t,e,r,n)}if(i&&"close"===s&&"open"===i.role){const o=c.getInstance().horizontalFencedNode_(r.pop(),t.shift(),n.pop());return n.push(n.pop().concat([o],e.shift())),c.getInstance().fences_(t,e,r,n)}if(i&&a.compareNeutralFences(t[0],i)){if(!a.elligibleLeftNeutral(i)||!a.elligibleRightNeutral(t[0])){r.push(t.shift());const o=e.shift();return o&&n.push(o),c.getInstance().fences_(t,e,r,n)}const o=c.getInstance().horizontalFencedNode_(r.pop(),t.shift(),n.pop());return n.push(n.pop().concat([o],e.shift())),c.getInstance().fences_(t,e,r,n)}if(i&&"close"===s&&a.isNeutralFence(i)&&r.some(o)){const i=l.sliceNodes(r,o,!0),s=n.pop(),a=n.length-i.tail.length+1,u=c.getInstance().neutralFences_(i.tail,n.slice(a));n=n.slice(0,a);const p=c.getInstance().horizontalFencedNode_(i.div,t.shift(),n.pop().concat(u,s));return n.push(n.pop().concat([p],e.shift())),c.getInstance().fences_(t,e,i.head,n)}const u=t.shift();return c.fenceToPunct_(u),n.push(n.pop().concat([u],e.shift())),c.getInstance().fences_(t,e,r,n)}neutralFences_(t,e){if(0===t.length)return t;if(1===t.length)return c.fenceToPunct_(t[0]),t;const r=t.shift();if(!a.elligibleLeftNeutral(r)){c.fenceToPunct_(r);const n=e.shift();return n.unshift(r),n.concat(c.getInstance().neutralFences_(t,e))}const n=l.sliceNodes(t,(function(t){return a.compareNeutralFences(t,r)}));if(!n.div){c.fenceToPunct_(r);const n=e.shift();return n.unshift(r),n.concat(c.getInstance().neutralFences_(t,e))}if(!a.elligibleRightNeutral(n.div))return c.fenceToPunct_(n.div),t.unshift(r),c.getInstance().neutralFences_(t,e);const o=c.getInstance().combineFencedContent_(r,n.div,n.head,e);if(n.tail.length>0){const t=o.shift(),e=c.getInstance().neutralFences_(n.tail,o);return t.concat(e)}return o[0]}combineFencedContent_(t,e,r,n){if(0===r.length){const r=c.getInstance().horizontalFencedNode_(t,e,n.shift());return n.length>0?n[0].unshift(r):n=[[r]],n}const o=n.shift(),i=r.length-1,s=n.slice(0,i),a=(n=n.slice(i)).shift(),l=c.getInstance().neutralFences_(r,s);o.push(...l),o.push(...a);const u=c.getInstance().horizontalFencedNode_(t,e,o);return n.length>0?n[0].unshift(u):n=[[u]],n}horizontalFencedNode_(t,e,r){const n=c.getInstance().row(r);let o=c.getInstance().factory_.makeBranchNode("fenced",[n],[t,e]);return"open"===t.role?(c.getInstance().classifyHorizontalFence_(o),o=i.run("propagateComposedFunction",o)):o.role=t.role,o=i.run("detect_cycle",o),c.rewriteFencedNode_(o)}classifyHorizontalFence_(t){t.role="leftright";const e=t.childNodes;if(!a.isSetNode(t)||e.length>1)return;if(0===e.length||"empty"===e[0].type)return void(t.role="set empty");const r=e[0].type;if(1===e.length&&a.isSingletonSetContent(e[0]))return void(t.role="set singleton");const n=e[0].role;if("punctuated"===r&&"sequence"===n){if("comma"!==e[0].contentNodes[0].role)return 1!==e[0].contentNodes.length||"vbar"!==e[0].contentNodes[0].role&&"colon"!==e[0].contentNodes[0].role?void 0:(t.role="set extended",void c.getInstance().setExtension_(t));t.role="set collection"}}setExtension_(t){const e=t.childNodes[0].childNodes[0];e&&"infixop"===e.type&&1===e.contentNodes.length&&a.isMembership(e.contentNodes[0])&&(e.addAnnotation("set","intensional"),e.contentNodes[0].addAnnotation("set","intensional"))}getPunctuationInRow_(t){if(t.length<=1)return t;const e=t=>{const e=t.type;return"punctuation"===e||"text"===e||"operator"===e||"relation"===e},r=l.partitionNodes(t,(function(r){if(!a.isPunctuation(r))return!1;if(a.isPunctuation(r)&&!a.isRole(r,"ellipsis"))return!0;const n=t.indexOf(r);if(0===n)return!t[1]||!e(t[1]);const o=t[n-1];if(n===t.length-1)return!e(o);const i=t[n+1];return!e(o)||!e(i)}));if(0===r.rel.length)return t;const n=[];let o=r.comp.shift();o.length>0&&n.push(c.getInstance().row(o));let i=0;for(;r.comp.length>0;)n.push(r.rel[i++]),o=r.comp.shift(),o.length>0&&n.push(c.getInstance().row(o));return[c.getInstance().punctuatedNode_(n,r.rel)]}punctuatedNode_(t,e){const r=c.getInstance().factory_.makeBranchNode("punctuated",t,e);if(e.length===t.length){const t=e[0].role;if("unknown"!==t&&e.every((function(e){return e.role===t})))return r.role=t,r}return a.singlePunctAtPosition(t,e,0)?r.role="startpunct":a.singlePunctAtPosition(t,e,t.length-1)?r.role="endpunct":e.every((t=>a.isRole(t,"dummy")))?r.role="text":e.every((t=>a.isRole(t,"space")))?r.role="space":r.role="sequence",r}dummyNode_(t){const e=c.getInstance().factory_.makeMultipleContentNodes(t.length-1,o.invisibleComma());return e.forEach((function(t){t.role="dummy"})),c.getInstance().punctuatedNode_(t,e)}accentRole_(t,e){if(!a.isAccent(t))return!1;const r=t.textContent,n=o.lookupSecondary("bar",r)||o.lookupSecondary("tilde",r)||t.role;return t.role="underscore"===e?"underaccent":"overaccent",t.addAnnotation("accent",n),!0}accentNode_(t,e,r,n,o){const i=(e=e.slice(0,n+1))[1],s=e[2];let a;if(!o&&s&&(a=c.getInstance().factory_.makeBranchNode("subscript",[t,i],[]),a.role="subsup",e=[a,s],r="superscript"),o){const n=c.getInstance().accentRole_(i,r);if(s){c.getInstance().accentRole_(s,"overscore")&&!n?(a=c.getInstance().factory_.makeBranchNode("overscore",[t,s],[]),e=[a,i],r="underscore"):(a=c.getInstance().factory_.makeBranchNode("underscore",[t,i],[]),e=[a,s],r="overscore"),a.role="underover"}}return c.getInstance().makeLimitNode_(t,e,a,r)}makeLimitNode_(t,e,r,n){if("limupper"===n&&"limlower"===t.type)return t.childNodes.push(e[1]),e[1].parent=t,t.type="limboth",t;if("limlower"===n&&"limupper"===t.type)return t.childNodes.splice(1,-1,e[1]),e[1].parent=t,t.type="limboth",t;const o=c.getInstance().factory_.makeBranchNode(n,e,[]),i=a.isEmbellished(t);return r&&(r.embellished=i),o.embellished=i,o.role=t.role,o}getFunctionsInRow_(t,e){const r=e||[];if(0===t.length)return r;const n=t.shift(),o=c.classifyFunction_(n,t);if(!o)return r.push(n),c.getInstance().getFunctionsInRow_(t,r);const i=c.getInstance().getFunctionsInRow_(t,[]),s=c.getInstance().getFunctionArgs_(n,i,o);return r.concat(s)}getFunctionArgs_(t,e,r){let n,o,i;switch(r){case"integral":{const r=c.getInstance().getIntegralArgs_(e);if(!r.intvar&&!r.integrand.length)return r.rest.unshift(t),r.rest;const n=c.getInstance().row(r.integrand);return i=c.getInstance().integralNode_(t,n,r.intvar),r.rest.unshift(i),r.rest}case"prefix":if(e[0]&&"fenced"===e[0].type){const r=e.shift();return a.isNeutralFence(r)||(r.role="leftright"),i=c.getInstance().functionNode_(t,r),e.unshift(i),e}if(n=l.sliceNodes(e,a.isPrefixFunctionBoundary),n.head.length)o=c.getInstance().row(n.head),n.div&&n.tail.unshift(n.div);else{if(!n.div||!a.isType(n.div,"appl"))return e.unshift(t),e;o=n.div}return i=c.getInstance().functionNode_(t,o),n.tail.unshift(i),n.tail;case"bigop":return n=l.sliceNodes(e,a.isBigOpBoundary),n.head.length?(o=c.getInstance().row(n.head),i=c.getInstance().bigOpNode_(t,o),n.div&&n.tail.unshift(n.div),n.tail.unshift(i),n.tail):(e.unshift(t),e);default:{if(0===e.length)return[t];const r=e[0];return"fenced"===r.type&&!a.isNeutralFence(r)&&a.isSimpleFunctionScope(r)?(r.role="leftright",c.propagateFunctionRole_(t,"simple function"),i=c.getInstance().functionNode_(t,e.shift()),e.unshift(i),e):(e.unshift(t),e)}}}getIntegralArgs_(t,e=[]){if(0===t.length)return{integrand:e,intvar:null,rest:t};const r=t[0];if(a.isGeneralFunctionBoundary(r))return{integrand:e,intvar:null,rest:t};if(a.isIntegralDxBoundarySingle(r))return r.role="integral",{integrand:e,intvar:r,rest:t.slice(1)};if(t[1]&&a.isIntegralDxBoundary(r,t[1])){const n=c.getInstance().prefixNode_(t[1],[r]);return n.role="integral",{integrand:e,intvar:n,rest:t.slice(2)}}return e.push(t.shift()),c.getInstance().getIntegralArgs_(t,e)}functionNode_(t,e){const r=c.getInstance().factory_.makeContentNode(o.functionApplication()),n=c.getInstance().funcAppls[t.id];n&&(r.mathmlTree=n.mathmlTree,r.mathml=n.mathml,r.annotation=n.annotation,r.attributes=n.attributes,delete c.getInstance().funcAppls[t.id]),r.type="punctuation",r.role="application";const i=c.getFunctionOp_(t,(function(t){return a.isType(t,"function")||a.isType(t,"identifier")&&a.isRole(t,"simple function")}));return c.getInstance().functionalNode_("appl",[t,e],i,[r])}bigOpNode_(t,e){const r=c.getFunctionOp_(t,(t=>a.isType(t,"largeop")));return c.getInstance().functionalNode_("bigop",[t,e],r,[])}integralNode_(t,e,r){e=e||c.getInstance().factory_.makeEmptyNode(),r=r||c.getInstance().factory_.makeEmptyNode();const n=c.getFunctionOp_(t,(t=>a.isType(t,"largeop")));return c.getInstance().functionalNode_("integral",[t,e,r],n,[])}functionalNode_(t,e,r,n){const o=e[0];let i;r&&(i=r.parent,n.push(r));const s=c.getInstance().factory_.makeBranchNode(t,e,n);return s.role=o.role,i&&(r.parent=i),s}fractionNode_(t,e){const r=c.getInstance().factory_.makeBranchNode("fraction",[t,e],[]);return r.role=r.childNodes.every((function(t){return a.isType(t,"number")&&a.isRole(t,"integer")}))?"vulgar":r.childNodes.every(a.isPureUnit)?"unit":"division",i.run("propagateSimpleFunction",r)}scriptNode_(t,e,r){let n;switch(t.length){case 0:n=c.getInstance().factory_.makeEmptyNode();break;case 1:if(n=t[0],r)return n;break;default:n=c.getInstance().dummyNode_(t)}return n.role=e,n}findNestedRow_(t,e,r,o){if(r>3)return null;for(let i,s=0;i=t[s];s++){const t=n.tagName(i);if("MSPACE"!==t){if("MROW"===t)return c.getInstance().findNestedRow_(n.toArray(i.childNodes),e,r+1,o);if(c.findSemantics(i,e,o))return i}}return null}}e.default=c,c.FENCE_TO_PUNCT_={metric:"metric",neutral:"vbar",open:"openfence",close:"closefence"},c.MML_TO_LIMIT_={MSUB:{type:"limlower",length:1},MUNDER:{type:"limlower",length:1},MSUP:{type:"limupper",length:1},MOVER:{type:"limupper",length:1},MSUBSUP:{type:"limboth",length:2},MUNDEROVER:{type:"limboth",length:2}},c.MML_TO_BOUNDS_={MSUB:{type:"subscript",length:1,accent:!1},MSUP:{type:"superscript",length:1,accent:!1},MSUBSUP:{type:"subscript",length:2,accent:!1},MUNDER:{type:"underscore",length:1,accent:!0},MOVER:{type:"overscore",length:1,accent:!0},MUNDEROVER:{type:"underscore",length:2,accent:!0}},c.CLASSIFY_FUNCTION_={integral:"integral",sum:"bigop","prefix function":"prefix","limit function":"prefix","simple function":"prefix","composed function":"prefix"},c.MATHJAX_FONTS={"-tex-caligraphic":"caligraphic","-tex-caligraphic-bold":"caligraphic-bold","-tex-calligraphic":"caligraphic","-tex-calligraphic-bold":"caligraphic-bold","-tex-oldstyle":"oldstyle","-tex-oldstyle-bold":"oldstyle-bold","-tex-mathit":"italic"}},5656:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticSkeleton=void 0;const n=r(707),o=r(5274),i=r(2298);class s{constructor(t){this.parents=null,this.levelsMap=null,t=0===t?t:t||[],this.array=t}static fromTree(t){return s.fromNode(t.root)}static fromNode(t){return new s(s.fromNode_(t))}static fromString(t){return new s(s.fromString_(t))}static simpleCollapseStructure(t){return"number"==typeof t}static contentCollapseStructure(t){return!!t&&!s.simpleCollapseStructure(t)&&"c"===t[0]}static interleaveIds(t,e){return n.interleaveLists(s.collapsedLeafs(t),s.collapsedLeafs(e))}static collapsedLeafs(...t){return t.reduce(((t,e)=>{return t.concat((r=e,s.simpleCollapseStructure(r)?[r]:(r=r,s.contentCollapseStructure(r[1])?r.slice(2):r.slice(1))));var r}),[])}static fromStructure(t,e){return new s(s.tree_(t,e.root))}static combineContentChildren(t,e,r){switch(t.type){case"relseq":case"infixop":case"multirel":return n.interleaveLists(r,e);case"prefixop":return e.concat(r);case"postfixop":return r.concat(e);case"fenced":return r.unshift(e[0]),r.push(e[1]),r;case"appl":return[r[0],e[0],r[1]];case"root":return[r[1],r[0]];case"row":case"line":return e.length&&r.unshift(e[0]),r;default:return r}}static makeSexp_(t){return s.simpleCollapseStructure(t)?t.toString():s.contentCollapseStructure(t)?"(c "+t.slice(1).map(s.makeSexp_).join(" ")+")":"("+t.map(s.makeSexp_).join(" ")+")"}static fromString_(t){let e=t.replace(/\(/g,"[");return e=e.replace(/\)/g,"]"),e=e.replace(/ /g,","),e=e.replace(/c/g,'"c"'),JSON.parse(e)}static fromNode_(t){if(!t)return[];const e=t.contentNodes;let r;e.length&&(r=e.map(s.fromNode_),r.unshift("c"));const n=t.childNodes;if(!n.length)return e.length?[t.id,r]:t.id;const o=n.map(s.fromNode_);return e.length&&o.unshift(r),o.unshift(t.id),o}static tree_(t,e){if(!e)return[];if(!e.childNodes.length)return e.id;const r=e.id,n=[r],a=o.evalXPath(`.//self::*[@${i.Attribute.ID}=${r}]`,t)[0],l=s.combineContentChildren(e,e.contentNodes.map((function(t){return t})),e.childNodes.map((function(t){return t})));a&&s.addOwns_(a,l);for(let e,r=0;e=l[r];r++)n.push(s.tree_(t,e));return n}static addOwns_(t,e){const r=t.getAttribute(i.Attribute.COLLAPSED),n=r?s.realLeafs_(s.fromString(r).array):e.map((t=>t.id));t.setAttribute(i.Attribute.OWNS,n.join(" "))}static realLeafs_(t){if(s.simpleCollapseStructure(t))return[t];if(s.contentCollapseStructure(t))return[];t=t;let e=[];for(let r=1;rs.simpleCollapseStructure(t)?t:s.contentCollapseStructure(t)?t[1]:t[0]))}subtreeNodes(t){if(!this.isRoot(t))return[];const e=(t,r)=>{s.simpleCollapseStructure(t)?r.push(t):(t=t,s.contentCollapseStructure(t)&&(t=t.slice(1)),t.forEach((t=>e(t,r))))},r=this.levelsMap[t],n=[];return e(r.slice(1),n),n}}e.SemanticSkeleton=s},7075:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SemanticTree=void 0;const n=r(5740),o=r(7630),i=r(9265),s=r(7228),a=r(5952),l=r(5609);r(94);class c{constructor(t){this.mathml=t,this.parser=new s.SemanticMathml,this.root=this.parser.parse(t),this.collator=this.parser.getFactory().leafMap.collateMeaning();const e=this.collator.newDefault();e&&(this.parser=new s.SemanticMathml,this.parser.getFactory().defaultMap=e,this.root=this.parser.parse(t)),u.visit(this.root,{}),(0,o.annotate)(this.root)}static empty(){const t=n.parseInput(""),e=new c(t);return e.mathml=t,e}static fromNode(t,e){const r=c.empty();return r.root=t,e&&(r.mathml=e),r}static fromRoot(t,e){let r=t;for(;r.parent;)r=r.parent;const n=c.fromNode(r);return e&&(n.mathml=e),n}static fromXml(t){const e=c.empty();return t.childNodes[0]&&(e.root=a.SemanticNode.fromXml(t.childNodes[0])),e}xml(t){const e=n.parseInput(""),r=this.root.xml(e.ownerDocument,t);return e.appendChild(r),e}toString(t){return n.serializeXml(this.xml(t))}formatXml(t){const e=this.toString(t);return n.formatXml(e)}displayTree(){this.root.displayTree()}replaceNode(t,e){const r=t.parent;r?r.replaceChild(t,e):this.root=e}toJson(){const t={};return t.stree=this.root.toJson(),t}}e.SemanticTree=c;const u=new i.SemanticVisitor("general","unit",((t,e)=>{if("infixop"===t.type&&("multiplication"===t.role||"implicit"===t.role)){const e=t.childNodes;e.length&&(l.isPureUnit(e[0])||l.isUnitCounter(e[0]))&&t.childNodes.slice(1).every(l.isPureUnit)&&(t.role="unit")}return!1}))},4795:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.partitionNodes=e.sliceNodes=e.getEmbellishedInner=e.addAttributes=e.isZeroLength=e.purgeNodes=e.isOrphanedGlyph=e.hasDisplayTag=e.hasEmptyTag=e.hasIgnoreTag=e.hasLeafTag=e.hasMathTag=e.directSpeechKeys=e.DISPLAYTAGS=e.EMPTYTAGS=e.IGNORETAGS=e.LEAFTAGS=void 0;const n=r(5740);function o(t){return!!t&&-1!==e.LEAFTAGS.indexOf(n.tagName(t))}function i(t,e,r){r&&t.reverse();const n=[];for(let o,i=0;o=t[i];i++){if(e(o))return r?{head:t.slice(i+1).reverse(),div:o,tail:n.reverse()}:{head:n,div:o,tail:t.slice(i+1)};n.push(o)}return r?{head:[],div:null,tail:n.reverse()}:{head:n,div:null,tail:[]}}e.LEAFTAGS=["MO","MI","MN","MTEXT","MS","MSPACE"],e.IGNORETAGS=["MERROR","MPHANTOM","MALIGNGROUP","MALIGNMARK","MPRESCRIPTS","ANNOTATION","ANNOTATION-XML"],e.EMPTYTAGS=["MATH","MROW","MPADDED","MACTION","NONE","MSTYLE","SEMANTICS"],e.DISPLAYTAGS=["MROOT","MSQRT"],e.directSpeechKeys=["aria-label","exact-speech","alt"],e.hasMathTag=function(t){return!!t&&"MATH"===n.tagName(t)},e.hasLeafTag=o,e.hasIgnoreTag=function(t){return!!t&&-1!==e.IGNORETAGS.indexOf(n.tagName(t))},e.hasEmptyTag=function(t){return!!t&&-1!==e.EMPTYTAGS.indexOf(n.tagName(t))},e.hasDisplayTag=function(t){return!!t&&-1!==e.DISPLAYTAGS.indexOf(n.tagName(t))},e.isOrphanedGlyph=function(t){return!!t&&"MGLYPH"===n.tagName(t)&&!o(t.parentNode)},e.purgeNodes=function(t){const r=[];for(let o,i=0;o=t[i];i++){if(o.nodeType!==n.NodeType.ELEMENT_NODE)continue;const t=n.tagName(o);-1===e.IGNORETAGS.indexOf(t)&&(-1!==e.EMPTYTAGS.indexOf(t)&&0===o.childNodes.length||r.push(o))}return r},e.isZeroLength=function(t){if(!t)return!1;if(-1!==["negativeveryverythinmathspace","negativeverythinmathspace","negativethinmathspace","negativemediummathspace","negativethickmathspace","negativeverythickmathspace","negativeveryverythickmathspace"].indexOf(t))return!0;const e=t.match(/[0-9.]+/);return!!e&&0===parseFloat(e[0])},e.addAttributes=function(t,r){if(r.hasAttributes()){const n=r.attributes;for(let r=n.length-1;r>=0;r--){const o=n[r].name;o.match(/^ext/)&&(t.attributes[o]=n[r].value,t.nobreaking=!0),-1!==e.directSpeechKeys.indexOf(o)&&(t.attributes["ext-speech"]=n[r].value,t.nobreaking=!0),o.match(/texclass$/)&&(t.attributes.texclass=n[r].value),"href"===o&&(t.attributes.href=n[r].value,t.nobreaking=!0)}}},e.getEmbellishedInner=function t(e){return e&&e.embellished&&e.childNodes.length>0?t(e.childNodes[0]):e},e.sliceNodes=i,e.partitionNodes=function(t,e){let r=t;const n=[],o=[];let s=null;do{s=i(r,e),o.push(s.head),n.push(s.div),r=s.tail}while(s.div);return n.pop(),{rel:n,comp:o}}},6278:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractSpeechGenerator=void 0;const n=r(6828),o=r(2298),i=r(1214),s=r(9543);e.AbstractSpeechGenerator=class{constructor(){this.modality=o.addPrefix("speech"),this.rebuilt_=null,this.options_={}}getRebuilt(){return this.rebuilt_}setRebuilt(t){this.rebuilt_=t}setOptions(t){this.options_=t||{},this.modality=o.addPrefix(this.options_.modality||"speech")}getOptions(){return this.options_}start(){}end(){}generateSpeech(t,e){return this.rebuilt_||(this.rebuilt_=new i.RebuildStree(e)),(0,n.setup)(this.options_),s.computeMarkup(this.getRebuilt().xml)}}},1452:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AdhocSpeechGenerator=void 0;const n=r(6278);class o extends n.AbstractSpeechGenerator{getSpeech(t,e){const r=this.generateSpeech(t,e);return t.setAttribute(this.modality,r),r}}e.AdhocSpeechGenerator=o},5152:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.ColorGenerator=void 0;const n=r(2298),o=r(8396),i=r(1214),s=r(1204),a=r(6278);class l extends a.AbstractSpeechGenerator{constructor(){super(...arguments),this.modality=(0,n.addPrefix)("foreground"),this.contrast=new o.ContrastPicker}static visitStree_(t,e,r){if(t.childNodes.length){if(t.contentNodes.length&&("punctuated"===t.type&&t.contentNodes.forEach((t=>r[t.id]=!0)),"implicit"!==t.role&&e.push(t.contentNodes.map((t=>t.id)))),t.childNodes.length){if("implicit"===t.role){const n=[];let o=[];for(const e of t.childNodes){const t=[];l.visitStree_(e,t,r),t.length<=2&&n.push(t.shift()),o=o.concat(t)}return e.push(n),void o.forEach((t=>e.push(t)))}t.childNodes.forEach((t=>l.visitStree_(t,e,r)))}}else r[t.id]||e.push(t.id)}getSpeech(t,e){return s.getAttribute(t,this.modality)}generateSpeech(t,e){return this.getRebuilt()||this.setRebuilt(new i.RebuildStree(t)),this.colorLeaves_(t),s.getAttribute(t,this.modality)}colorLeaves_(t){const e=[];l.visitStree_(this.getRebuilt().streeRoot,e,{});for(const r of e){const e=this.contrast.generate();let n=!1;n=Array.isArray(r)?r.map((r=>this.colorLeave_(t,r,e))).reduce(((t,e)=>t||e),!1):this.colorLeave_(t,r.toString(),e),n&&this.contrast.increment()}}colorLeave_(t,e,r){const n=s.getBySemanticId(t,e);return!!n&&(n.setAttribute(this.modality,r),!0)}}e.ColorGenerator=l},6604:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DirectSpeechGenerator=void 0;const n=r(1204),o=r(6278);class i extends o.AbstractSpeechGenerator{getSpeech(t,e){return n.getAttribute(t,this.modality)}}e.DirectSpeechGenerator=i},3123:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DummySpeechGenerator=void 0;const n=r(6278);class o extends n.AbstractSpeechGenerator{getSpeech(t,e){return""}}e.DummySpeechGenerator=o},5858:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.NodeSpeechGenerator=void 0;const n=r(1204),o=r(4598);class i extends o.TreeSpeechGenerator{getSpeech(t,e){return super.getSpeech(t,e),n.getAttribute(t,this.modality)}}e.NodeSpeechGenerator=i},9552:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.generatorMapping_=e.generator=void 0;const n=r(1452),o=r(5152),i=r(6604),s=r(3123),a=r(5858),l=r(597),c=r(4598);e.generator=function(t){return(e.generatorMapping_[t]||e.generatorMapping_.Direct)()},e.generatorMapping_={Adhoc:()=>new n.AdhocSpeechGenerator,Color:()=>new o.ColorGenerator,Direct:()=>new i.DirectSpeechGenerator,Dummy:()=>new s.DummySpeechGenerator,Node:()=>new a.NodeSpeechGenerator,Summary:()=>new l.SummarySpeechGenerator,Tree:()=>new c.TreeSpeechGenerator}},9543:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.computeSummary_=e.retrieveSummary=e.connectAllMactions=e.connectMactions=e.nodeAtPosition_=e.computePrefix_=e.retrievePrefix=e.addPrefix=e.addModality=e.addSpeech=e.recomputeMarkup=e.computeMarkup=e.recomputeSpeech=e.computeSpeech=void 0;const n=r(8290),o=r(5740),i=r(5274),s=r(2298),a=r(2362),l=r(7075),c=r(1204);function u(t){return a.SpeechRuleEngine.getInstance().evaluateNode(t)}function p(t){return u(l.SemanticTree.fromNode(t).xml())}function h(t){const e=p(t);return n.markup(e)}function f(t){const e=d(t);return n.markup(e)}function d(t){const e=l.SemanticTree.fromRoot(t),r=i.evalXPath('.//*[@id="'+t.id+'"]',e.xml());let n=r[0];return r.length>1&&(n=m(t,r)||n),n?a.SpeechRuleEngine.getInstance().runInSetting({modality:"prefix",domain:"default",style:"default",strict:!0,speech:!0},(function(){return a.SpeechRuleEngine.getInstance().evaluateNode(n)})):[]}function m(t,e){const r=e[0];if(!t.parent)return r;const n=[];for(;t;)n.push(t.id),t=t.parent;const o=function(t,e){for(;e.length&&e.shift().toString()===t.getAttribute("id")&&t.parentNode&&t.parentNode.parentNode;)t=t.parentNode.parentNode;return!e.length};for(let t,r=0;t=e[r];r++)if(o(t,n.slice()))return t;return r}function y(t){return t?a.SpeechRuleEngine.getInstance().runInSetting({modality:"summary",strict:!1,speech:!0},(function(){return a.SpeechRuleEngine.getInstance().evaluateNode(t)})):[]}e.computeSpeech=u,e.recomputeSpeech=p,e.computeMarkup=function(t){const e=u(t);return n.markup(e)},e.recomputeMarkup=h,e.addSpeech=function(t,e,r){const i=o.querySelectorAllByAttrValue(r,"id",e.id.toString())[0],a=i?n.markup(u(i)):h(e);t.setAttribute(s.Attribute.SPEECH,a)},e.addModality=function(t,e,r){const n=h(e);t.setAttribute(r,n)},e.addPrefix=function(t,e){const r=f(e);r&&t.setAttribute(s.Attribute.PREFIX,r)},e.retrievePrefix=f,e.computePrefix_=d,e.nodeAtPosition_=m,e.connectMactions=function(t,e,r){const n=o.querySelectorAll(e,"maction");for(let e,i=0;e=n[i];i++){const n=e.getAttribute("id"),i=o.querySelectorAllByAttrValue(t,"id",n)[0];if(!i)continue;const a=e.childNodes[1],l=a.getAttribute(s.Attribute.ID);let u=c.getBySemanticId(t,l);if(u&&"dummy"!==u.getAttribute(s.Attribute.TYPE))continue;if(u=i.childNodes[0],u.getAttribute("sre-highlighter-added"))continue;const p=a.getAttribute(s.Attribute.PARENT);p&&u.setAttribute(s.Attribute.PARENT,p),u.setAttribute(s.Attribute.TYPE,"dummy"),u.setAttribute(s.Attribute.ID,l);o.querySelectorAllByAttrValue(r,"id",l)[0].setAttribute("alternative",l)}},e.connectAllMactions=function(t,e){const r=o.querySelectorAll(t,"maction");for(let t,n=0;t=r[n];n++){const r=t.childNodes[1].getAttribute(s.Attribute.ID);o.querySelectorAllByAttrValue(e,"id",r)[0].setAttribute("alternative",r)}},e.retrieveSummary=function(t){const e=y(t);return n.markup(e)},e.computeSummary_=y},597:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SummarySpeechGenerator=void 0;const n=r(6278),o=r(9543);class i extends n.AbstractSpeechGenerator{getSpeech(t,e){return o.connectAllMactions(e,this.getRebuilt().xml),this.generateSpeech(t,e)}}e.SummarySpeechGenerator=i},4598:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.TreeSpeechGenerator=void 0;const n=r(2298),o=r(1204),i=r(6278),s=r(9543);class a extends i.AbstractSpeechGenerator{getSpeech(t,e){const r=this.generateSpeech(t,e),i=this.getRebuilt().nodeDict;for(const r in i){const a=i[r],l=o.getBySemanticId(e,r),c=o.getBySemanticId(t,r);l&&c&&(this.modality&&this.modality!==n.Attribute.SPEECH?s.addModality(c,a,this.modality):s.addSpeech(c,a,this.getRebuilt().xml),this.modality===n.Attribute.SPEECH&&s.addPrefix(c,a))}return r}}e.TreeSpeechGenerator=a},313:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.INTERVALS=e.makeLetter=e.numberRules=e.alphabetRules=e.getFont=e.makeInterval=e.generate=e.makeDomains_=e.Domains_=e.Base=e.Embellish=e.Font=void 0;const n=r(5897),o=r(7491),i=r(4356),s=r(2536),a=r(2780);var l,c,u;function p(){const t=i.LOCALE.ALPHABETS,r=(t,e)=>{const r={};return Object.keys(t).forEach((t=>r[t]=!0)),Object.keys(e).forEach((t=>r[t]=!0)),Object.keys(r)};e.Domains_.small=r(t.smallPrefix,t.letterTrans),e.Domains_.capital=r(t.capPrefix,t.letterTrans),e.Domains_.digit=r(t.digitPrefix,t.digitTrans)}function h(t){const e=t.toString(16).toUpperCase();return e.length>3?e:("000"+e).slice(-4)}function f([t,e],r){const n=parseInt(t,16),o=parseInt(e,16),i=[];for(let t=n;t<=o;t++){let e=h(t);!1!==r[e]&&(e=r[e]||e,i.push(e))}return i}function d(t){const e="normal"===t||"fullwidth"===t?"":i.LOCALE.MESSAGES.font[t]||i.LOCALE.MESSAGES.embellish[t]||"";return(0,s.localeFontCombiner)(e)}function m(t,r,n,o,s,a){const l=d(o);for(let o,c,u,p=0;o=t[p],c=r[p],u=n[p];p++){const t=a?i.LOCALE.ALPHABETS.capPrefix:i.LOCALE.ALPHABETS.smallPrefix,r=a?e.Domains_.capital:e.Domains_.small;g(l.combiner,o,c,u,l.font,t,s,i.LOCALE.ALPHABETS.letterTrans,r)}}function y(t,r,n,o,s){const a=d(n);for(let n,l,c=0;n=t[c],l=r[c];c++){const t=i.LOCALE.ALPHABETS.digitPrefix,r=c+s;g(a.combiner,n,l,r,a.font,t,o,i.LOCALE.ALPHABETS.digitTrans,e.Domains_.digit)}}function g(t,e,r,n,o,i,s,l,c){for(let u,p=0;u=c[p];p++){const c=u in l?l[u]:l.default,p=u in i?i[u]:i.default;a.defineRule(e.toString(),u,"default",s,r,t(c(n),o,p))}}!function(t){t.BOLD="bold",t.BOLDFRAKTUR="bold-fraktur",t.BOLDITALIC="bold-italic",t.BOLDSCRIPT="bold-script",t.DOUBLESTRUCK="double-struck",t.FULLWIDTH="fullwidth",t.FRAKTUR="fraktur",t.ITALIC="italic",t.MONOSPACE="monospace",t.NORMAL="normal",t.SCRIPT="script",t.SANSSERIF="sans-serif",t.SANSSERIFITALIC="sans-serif-italic",t.SANSSERIFBOLD="sans-serif-bold",t.SANSSERIFBOLDITALIC="sans-serif-bold-italic"}(l=e.Font||(e.Font={})),function(t){t.SUPER="super",t.SUB="sub",t.CIRCLED="circled",t.PARENTHESIZED="parenthesized",t.PERIOD="period",t.NEGATIVECIRCLED="negative-circled",t.DOUBLECIRCLED="double-circled",t.CIRCLEDSANSSERIF="circled-sans-serif",t.NEGATIVECIRCLEDSANSSERIF="negative-circled-sans-serif",t.COMMA="comma",t.SQUARED="squared",t.NEGATIVESQUARED="negative-squared"}(c=e.Embellish||(e.Embellish={})),function(t){t.LATINCAP="latinCap",t.LATINSMALL="latinSmall",t.GREEKCAP="greekCap",t.GREEKSMALL="greekSmall",t.DIGIT="digit"}(u=e.Base||(e.Base={})),e.Domains_={small:["default"],capital:["default"],digit:["default"]},e.makeDomains_=p,e.generate=function(t){const r=n.default.getInstance().locale;n.default.getInstance().locale=t,o.setLocale(),a.addSymbolRules({locale:t}),p();const s=e.INTERVALS;for(let t,e=0;t=s[e];e++){const e=f(t.interval,t.subst),r=e.map((function(t){return String.fromCodePoint(parseInt(t,16))}));if("offset"in t)y(e,r,t.font,t.category,t.offset||0);else{m(e,r,i.LOCALE.ALPHABETS[t.base],t.font,t.category,!!t.capital)}}n.default.getInstance().locale=r,o.setLocale()},e.makeInterval=f,e.getFont=d,e.alphabetRules=m,e.numberRules=y,e.makeLetter=g,e.INTERVALS=[{interval:["1D400","1D419"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.BOLD},{interval:["1D41A","1D433"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLD},{interval:["1D56C","1D585"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.BOLDFRAKTUR},{interval:["1D586","1D59F"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLDFRAKTUR},{interval:["1D468","1D481"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.BOLDITALIC},{interval:["1D482","1D49B"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLDITALIC},{interval:["1D4D0","1D4E9"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.BOLDSCRIPT},{interval:["1D4EA","1D503"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLDSCRIPT},{interval:["1D538","1D551"],base:u.LATINCAP,subst:{"1D53A":"2102","1D53F":"210D","1D545":"2115","1D547":"2119","1D548":"211A","1D549":"211D","1D551":"2124"},capital:!0,category:"Lu",font:l.DOUBLESTRUCK},{interval:["1D552","1D56B"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.DOUBLESTRUCK},{interval:["1D504","1D51D"],base:u.LATINCAP,subst:{"1D506":"212D","1D50B":"210C","1D50C":"2111","1D515":"211C","1D51D":"2128"},capital:!0,category:"Lu",font:l.FRAKTUR},{interval:["1D51E","1D537"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.FRAKTUR},{interval:["FF21","FF3A"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.FULLWIDTH},{interval:["FF41","FF5A"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.FULLWIDTH},{interval:["1D434","1D44D"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.ITALIC},{interval:["1D44E","1D467"],base:u.LATINSMALL,subst:{"1D455":"210E"},capital:!1,category:"Ll",font:l.ITALIC},{interval:["1D670","1D689"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.MONOSPACE},{interval:["1D68A","1D6A3"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.MONOSPACE},{interval:["0041","005A"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.NORMAL},{interval:["0061","007A"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.NORMAL},{interval:["1D49C","1D4B5"],base:u.LATINCAP,subst:{"1D49D":"212C","1D4A0":"2130","1D4A1":"2131","1D4A3":"210B","1D4A4":"2110","1D4A7":"2112","1D4A8":"2133","1D4AD":"211B"},capital:!0,category:"Lu",font:l.SCRIPT},{interval:["1D4B6","1D4CF"],base:u.LATINSMALL,subst:{"1D4BA":"212F","1D4BC":"210A","1D4C4":"2134"},capital:!1,category:"Ll",font:l.SCRIPT},{interval:["1D5A0","1D5B9"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIF},{interval:["1D5BA","1D5D3"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIF},{interval:["1D608","1D621"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIFITALIC},{interval:["1D622","1D63B"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIFITALIC},{interval:["1D5D4","1D5ED"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIFBOLD},{interval:["1D5EE","1D607"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIFBOLD},{interval:["1D63C","1D655"],base:u.LATINCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIFBOLDITALIC},{interval:["1D656","1D66F"],base:u.LATINSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIFBOLDITALIC},{interval:["0391","03A9"],base:u.GREEKCAP,subst:{"03A2":"03F4"},capital:!0,category:"Lu",font:l.NORMAL},{interval:["03B0","03D0"],base:u.GREEKSMALL,subst:{"03B0":"2207","03CA":"2202","03CB":"03F5","03CC":"03D1","03CD":"03F0","03CE":"03D5","03CF":"03F1","03D0":"03D6"},capital:!1,category:"Ll",font:l.NORMAL},{interval:["1D6A8","1D6C0"],base:u.GREEKCAP,subst:{},capital:!0,category:"Lu",font:l.BOLD},{interval:["1D6C1","1D6E1"],base:u.GREEKSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLD},{interval:["1D6E2","1D6FA"],base:u.GREEKCAP,subst:{},capital:!0,category:"Lu",font:l.ITALIC},{interval:["1D6FB","1D71B"],base:u.GREEKSMALL,subst:{},capital:!1,category:"Ll",font:l.ITALIC},{interval:["1D71C","1D734"],base:u.GREEKCAP,subst:{},capital:!0,category:"Lu",font:l.BOLDITALIC},{interval:["1D735","1D755"],base:u.GREEKSMALL,subst:{},capital:!1,category:"Ll",font:l.BOLDITALIC},{interval:["1D756","1D76E"],base:u.GREEKCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIFBOLD},{interval:["1D76F","1D78F"],base:u.GREEKSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIFBOLD},{interval:["1D790","1D7A8"],base:u.GREEKCAP,subst:{},capital:!0,category:"Lu",font:l.SANSSERIFBOLDITALIC},{interval:["1D7A9","1D7C9"],base:u.GREEKSMALL,subst:{},capital:!1,category:"Ll",font:l.SANSSERIFBOLDITALIC},{interval:["0030","0039"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.NORMAL},{interval:["2070","2079"],base:u.DIGIT,subst:{2071:"00B9",2072:"00B2",2073:"00B3"},offset:0,category:"No",font:c.SUPER},{interval:["2080","2089"],base:u.DIGIT,subst:{},offset:0,category:"No",font:c.SUB},{interval:["245F","2473"],base:u.DIGIT,subst:{"245F":"24EA"},offset:0,category:"No",font:c.CIRCLED},{interval:["3251","325F"],base:u.DIGIT,subst:{},offset:21,category:"No",font:c.CIRCLED},{interval:["32B1","32BF"],base:u.DIGIT,subst:{},offset:36,category:"No",font:c.CIRCLED},{interval:["2474","2487"],base:u.DIGIT,subst:{},offset:1,category:"No",font:c.PARENTHESIZED},{interval:["2487","249B"],base:u.DIGIT,subst:{2487:"1F100"},offset:0,category:"No",font:c.PERIOD},{interval:["2775","277F"],base:u.DIGIT,subst:{2775:"24FF"},offset:0,category:"No",font:c.NEGATIVECIRCLED},{interval:["24EB","24F4"],base:u.DIGIT,subst:{},offset:11,category:"No",font:c.NEGATIVECIRCLED},{interval:["24F5","24FE"],base:u.DIGIT,subst:{},offset:1,category:"No",font:c.DOUBLECIRCLED},{interval:["277F","2789"],base:u.DIGIT,subst:{"277F":"1F10B"},offset:0,category:"No",font:c.CIRCLEDSANSSERIF},{interval:["2789","2793"],base:u.DIGIT,subst:{2789:"1F10C"},offset:0,category:"No",font:c.NEGATIVECIRCLEDSANSSERIF},{interval:["FF10","FF19"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.FULLWIDTH},{interval:["1D7CE","1D7D7"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.BOLD},{interval:["1D7D8","1D7E1"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.DOUBLESTRUCK},{interval:["1D7E2","1D7EB"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.SANSSERIF},{interval:["1D7EC","1D7F5"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.SANSSERIFBOLD},{interval:["1D7F6","1D7FF"],base:u.DIGIT,subst:{},offset:0,category:"Nd",font:l.MONOSPACE},{interval:["1F101","1F10A"],base:u.DIGIT,subst:{},offset:0,category:"No",font:c.COMMA},{interval:["24B6","24CF"],base:u.LATINCAP,subst:{},capital:!0,category:"So",font:c.CIRCLED},{interval:["24D0","24E9"],base:u.LATINSMALL,subst:{},capital:!1,category:"So",font:c.CIRCLED},{interval:["1F110","1F129"],base:u.LATINCAP,subst:{},capital:!0,category:"So",font:c.PARENTHESIZED},{interval:["249C","24B5"],base:u.LATINSMALL,subst:{},capital:!1,category:"So",font:c.PARENTHESIZED},{interval:["1F130","1F149"],base:u.LATINCAP,subst:{},capital:!0,category:"So",font:c.SQUARED},{interval:["1F170","1F189"],base:u.LATINCAP,subst:{},capital:!0,category:"So",font:c.NEGATIVESQUARED},{interval:["1F150","1F169"],base:u.LATINCAP,subst:{},capital:!0,category:"So",font:c.NEGATIVECIRCLED}]},8504:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.Parser=e.Comparator=e.ClearspeakPreferences=void 0;const n=r(5897),o=r(4440),i=r(1676),s=r(1676),a=r(2780),l=r(2362);class c extends i.DynamicCstr{constructor(t,e){super(t),this.preference=e}static comparator(){return new p(n.default.getInstance().dynamicCstr,s.DynamicProperties.createProp([i.DynamicCstr.DEFAULT_VALUES[s.Axis.LOCALE]],[i.DynamicCstr.DEFAULT_VALUES[s.Axis.MODALITY]],[i.DynamicCstr.DEFAULT_VALUES[s.Axis.DOMAIN]],[i.DynamicCstr.DEFAULT_VALUES[s.Axis.STYLE]]))}static fromPreference(t){const e=t.split(":"),r={},n=u.getProperties(),o=Object.keys(n);for(let t,i=0;t=e[i];i++){const e=t.split("_");if(-1===o.indexOf(e[0]))continue;const i=e[1];i&&i!==c.AUTO&&-1!==n[e[0]].indexOf(i)&&(r[e[0]]=e[1])}return r}static toPreference(t){const e=Object.keys(t),r=[];for(let n=0;ns?-1:i0&&e<20&&r>0&&r<11}function O(t){return o.default.getInstance().style===t}function x(t){if(!t.hasAttribute("annotation"))return!1;const e=t.getAttribute("annotation");return!!/clearspeak:simple$|clearspeak:simple;/.exec(e)}function E(t){if(x(t))return!0;if("subscript"!==t.tagName)return!1;const e=t.childNodes[0].childNodes,r=e[1];return"identifier"===e[0].tagName&&(A(r)||"infixop"===r.tagName&&r.hasAttribute("role")&&"implicit"===r.getAttribute("role")&&C(r))}function A(t){return"number"===t.tagName&&t.hasAttribute("role")&&"integer"===t.getAttribute("role")}function C(t){return i.evalXPath("children/*",t).every((t=>A(t)||"identifier"===t.tagName))}function T(t){return"text"===t.type||"punctuated"===t.type&&"text"===t.role&&_(t.childNodes[0])&&N(t.childNodes.slice(1))||"identifier"===t.type&&"unit"===t.role||"infixop"===t.type&&("implicit"===t.role||"unit"===t.role)}function N(t){for(let e=0;e10?s.LOCALE.NUMBERS.numericOrdinal(e):s.LOCALE.NUMBERS.wordOrdinal(e)},e.NESTING_DEPTH=null,e.nestingDepth=function(t){let r=0;const n=t.textContent,o="open"===t.getAttribute("role")?0:1;let i=t.parentNode;for(;i;)"fenced"===i.tagName&&i.childNodes[0].childNodes[o].textContent===n&&r++,i=i.parentNode;return e.NESTING_DEPTH=r>1?s.LOCALE.NUMBERS.wordOrdinal(r):"",e.NESTING_DEPTH},e.matchingFences=function(t){const e=t.previousSibling;let r,n;return e?(r=e,n=t):(r=t,n=t.nextSibling),n&&(0,h.isMatchingFence)(r.textContent,n.textContent)?[t]:[]},e.insertNesting=w,l.Grammar.getInstance().setCorrection("insertNesting",w),e.fencedArguments=function(t){const e=n.toArray(t.parentNode.childNodes),r=i.evalXPath("../../children/*",t),o=e.indexOf(t);return I(r[o])||I(r[o+1])?[t]:[]},e.simpleArguments=function(t){const e=n.toArray(t.parentNode.childNodes),r=i.evalXPath("../../children/*",t),o=e.indexOf(t);return L(r[o])&&r[o+1]&&(L(r[o+1])||"root"===r[o+1].tagName||"sqrt"===r[o+1].tagName||"superscript"===r[o+1].tagName&&r[o+1].childNodes[0].childNodes[0]&&("number"===r[o+1].childNodes[0].childNodes[0].tagName||"identifier"===r[o+1].childNodes[0].childNodes[0].tagName)&&("2"===r[o+1].childNodes[0].childNodes[1].textContent||"3"===r[o+1].childNodes[0].childNodes[1].textContent))?[t]:[]},e.simpleFactor_=L,e.fencedFactor_=I,e.layoutFactor_=P,e.wordOrdinal=function(t){return s.LOCALE.NUMBERS.wordOrdinal(parseInt(t.textContent,10))}},6141:function(t,e,r){var n=this&&this.__awaiter||function(t,e,r,n){return new(r||(r=Promise))((function(o,i){function s(t){try{l(n.next(t))}catch(t){i(t)}}function a(t){try{l(n.throw(t))}catch(t){i(t)}}function l(t){var e;t.done?o(t.value):(e=t.value,e instanceof r?e:new r((function(t){t(e)}))).then(s,a)}l((n=n.apply(t,e||[])).next())}))};Object.defineProperty(e,"__esModule",{value:!0}),e.loadAjax=e.loadFileSync=e.loadFile=e.parseMaps=e.retrieveFiles=e.standardLoader=e.loadLocale=e.store=void 0;const o=r(2139),i=r(5897),s=r(4440),a=r(7248),l=r(2315),c=r(1676),u=r(2780),p=r(2362),h=r(7491),f=r(313);e.store=u;const d={functions:u.addFunctionRules,symbols:u.addSymbolRules,units:u.addUnitRules,si:u.setSiPrefixes};let m=!1;function y(t=i.default.getInstance().locale){i.EnginePromise.loaded[t]||(i.EnginePromise.loaded[t]=[!1,!1],function(t){if(i.default.getInstance().isIE&&i.default.getInstance().mode===s.Mode.HTTP)return void S(t);b(t)}(t))}function g(){switch(i.default.getInstance().mode){case s.Mode.ASYNC:return M;case s.Mode.HTTP:return x;case s.Mode.SYNC:default:return O}}function b(t){const e=i.default.getInstance().customLoader?i.default.getInstance().customLoader:g(),r=new Promise((r=>{e(t).then((e=>{v(e),i.EnginePromise.loaded[t]=[!0,!0],r(t)}),(e=>{i.EnginePromise.loaded[t]=[!0,!1],console.error(`Unable to load locale: ${t}`),i.default.getInstance().locale=i.default.getInstance().defaultLocale,r(t)}))}));i.EnginePromise.promises[t]=r}function v(t){_(JSON.parse(t))}function _(t,e){let r=!0;for(let n,o=0;n=Object.keys(t)[o];o++){const o=n.split("/");e&&e!==o[0]||("rules"===o[1]?p.SpeechRuleEngine.getInstance().addStore(t[n]):"messages"===o[1]?(0,h.completeLocale)(t[n]):(r&&(f.generate(o[0]),r=!1),t[n].forEach(d[o[1]])))}}function S(t,e){let r=e||1;o.mapsForIE?_(o.mapsForIE,t):r<=5&&setTimeout((()=>S(t,r++)).bind(this),300)}function M(t){const e=a.localePath(t);return new Promise(((t,r)=>{l.default.fs.readFile(e,"utf8",((e,n)=>{if(e)return r(e);t(n)}))}))}function O(t){const e=a.localePath(t);return new Promise(((t,r)=>{let n="{}";try{n=l.default.fs.readFileSync(e,"utf8")}catch(t){return r(t)}t(n)}))}function x(t){const e=a.localePath(t),r=new XMLHttpRequest;return new Promise(((t,n)=>{r.onreadystatechange=function(){if(4===r.readyState){const e=r.status;0===e||e>=200&&e<400?t(r.responseText):n(e)}},r.open("GET",e,!0),r.send()}))}e.loadLocale=function(t=i.default.getInstance().locale){return n(this,void 0,void 0,(function*(){return m||(y(c.DynamicCstr.BASE_LOCALE),m=!0),i.EnginePromise.promises[c.DynamicCstr.BASE_LOCALE].then((()=>n(this,void 0,void 0,(function*(){const e=i.default.getInstance().defaultLocale;return e?(y(e),i.EnginePromise.promises[e].then((()=>n(this,void 0,void 0,(function*(){return y(t),i.EnginePromise.promises[t]}))))):(y(t),i.EnginePromise.promises[t])}))))}))},e.standardLoader=g,e.retrieveFiles=b,e.parseMaps=v,e.loadFile=M,e.loadFileSync=O,e.loadAjax=x},7088:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.leftSubscriptBrief=e.leftSuperscriptBrief=e.leftSubscriptVerbose=e.leftSuperscriptVerbose=e.baselineBrief=e.baselineVerbose=void 0;const n=r(1378);e.baselineVerbose=function(t){return n.baselineVerbose(t).replace(/-$/,"")},e.baselineBrief=function(t){return n.baselineBrief(t).replace(/-$/,"")},e.leftSuperscriptVerbose=function(t){return n.superscriptVerbose(t).replace(/^exposant/,"exposant gauche")},e.leftSubscriptVerbose=function(t){return n.subscriptVerbose(t).replace(/^indice/,"indice gauche")},e.leftSuperscriptBrief=function(t){return n.superscriptBrief(t).replace(/^sup/,"sup gauche")},e.leftSubscriptBrief=function(t){return n.subscriptBrief(t).replace(/^sub/,"sub gauche")}},9577:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.MathspeakRules=void 0;const n=r(1676),o=r(365),i=r(7088),s=r(1378),a=r(8437),l=r(7283),c=r(7598);e.MathspeakRules=function(){l.addStore(n.DynamicCstr.BASE_LOCALE+".speech.mathspeak","",{CQFspaceoutNumber:s.spaceoutNumber,CQFspaceoutIdentifier:s.spaceoutIdentifier,CSFspaceoutText:s.spaceoutText,CSFopenFracVerbose:s.openingFractionVerbose,CSFcloseFracVerbose:s.closingFractionVerbose,CSFoverFracVerbose:s.overFractionVerbose,CSFopenFracBrief:s.openingFractionBrief,CSFcloseFracBrief:s.closingFractionBrief,CSFopenFracSbrief:s.openingFractionSbrief,CSFcloseFracSbrief:s.closingFractionSbrief,CSFoverFracSbrief:s.overFractionSbrief,CSFvulgarFraction:a.vulgarFraction,CQFvulgarFractionSmall:s.isSmallVulgarFraction,CSFopenRadicalVerbose:s.openingRadicalVerbose,CSFcloseRadicalVerbose:s.closingRadicalVerbose,CSFindexRadicalVerbose:s.indexRadicalVerbose,CSFopenRadicalBrief:s.openingRadicalBrief,CSFcloseRadicalBrief:s.closingRadicalBrief,CSFindexRadicalBrief:s.indexRadicalBrief,CSFopenRadicalSbrief:s.openingRadicalSbrief,CSFindexRadicalSbrief:s.indexRadicalSbrief,CQFisSmallRoot:s.smallRoot,CSFsuperscriptVerbose:s.superscriptVerbose,CSFsuperscriptBrief:s.superscriptBrief,CSFsubscriptVerbose:s.subscriptVerbose,CSFsubscriptBrief:s.subscriptBrief,CSFbaselineVerbose:s.baselineVerbose,CSFbaselineBrief:s.baselineBrief,CSFleftsuperscriptVerbose:s.superscriptVerbose,CSFleftsubscriptVerbose:s.subscriptVerbose,CSFrightsuperscriptVerbose:s.superscriptVerbose,CSFrightsubscriptVerbose:s.subscriptVerbose,CSFleftsuperscriptBrief:s.superscriptBrief,CSFleftsubscriptBrief:s.subscriptBrief,CSFrightsuperscriptBrief:s.superscriptBrief,CSFrightsubscriptBrief:s.subscriptBrief,CSFunderscript:s.nestedUnderscript,CSFoverscript:s.nestedOverscript,CSFendscripts:s.endscripts,CTFordinalCounter:a.ordinalCounter,CTFwordCounter:a.wordCounter,CTFcontentIterator:o.contentIterator,CQFdetIsSimple:s.determinantIsSimple,CSFRemoveParens:s.removeParens,CQFresetNesting:s.resetNestingDepth,CGFbaselineConstraint:s.generateBaselineConstraint,CGFtensorRules:s.generateTensorRules}),l.addStore("es.speech.mathspeak",n.DynamicCstr.BASE_LOCALE+".speech.mathspeak",{CTFunitMultipliers:c.unitMultipliers,CQFoneLeft:c.oneLeft}),l.addStore("fr.speech.mathspeak",n.DynamicCstr.BASE_LOCALE+".speech.mathspeak",{CSFbaselineVerbose:i.baselineVerbose,CSFbaselineBrief:i.baselineBrief,CSFleftsuperscriptVerbose:i.leftSuperscriptVerbose,CSFleftsubscriptVerbose:i.leftSubscriptVerbose,CSFleftsuperscriptBrief:i.leftSuperscriptBrief,CSFleftsubscriptBrief:i.leftSubscriptBrief})}},1378:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.smallRoot=e.generateTensorRules=e.removeParens=e.generateBaselineConstraint=e.determinantIsSimple=e.nestedOverscript=e.endscripts=e.overscoreNestingDepth=e.nestedUnderscript=e.underscoreNestingDepth=e.indexRadicalSbrief=e.openingRadicalSbrief=e.indexRadicalBrief=e.closingRadicalBrief=e.openingRadicalBrief=e.indexRadicalVerbose=e.closingRadicalVerbose=e.openingRadicalVerbose=e.getRootIndex=e.nestedRadical=e.radicalNestingDepth=e.baselineBrief=e.baselineVerbose=e.superscriptBrief=e.superscriptVerbose=e.subscriptBrief=e.subscriptVerbose=e.nestedSubSuper=e.isSmallVulgarFraction=e.overFractionSbrief=e.closingFractionSbrief=e.openingFractionSbrief=e.closingFractionBrief=e.openingFractionBrief=e.overFractionVerbose=e.closingFractionVerbose=e.openingFractionVerbose=e.nestedFraction=e.fractionNestingDepth=e.computeNestingDepth_=e.containsAttr=e.getNestingDepth=e.resetNestingDepth=e.nestingBarriers=e.spaceoutIdentifier=e.spaceoutNumber=e.spaceoutNodes=e.spaceoutText=void 0;const n=r(707),o=r(5740),i=r(5274),s=r(4356),a=r(3308);let l={};function c(t,e){const r=Array.from(t.textContent),n=[],o=a.default.getInstance(),i=t.ownerDocument;for(let t,s=0;t=r[s];s++){const r=o.getNodeFactory().makeLeafNode(t,"unknown"),s=o.identifierNode(r,"unknown","");e(s),n.push(s.xml(i))}return n}function u(t,r,i,s,a,c){s=s||e.nestingBarriers,a=a||{},c=c||function(t){return!1};const u=o.serializeXml(r);if(l[t]||(l[t]={}),l[t][u])return l[t][u];if(c(r)||i.indexOf(r.tagName)<0)return 0;const p=h(r,i,n.setdifference(s,i),a,c,0);return l[t][u]=p,p}function p(t,e){if(!t.attributes)return!1;const r=o.toArray(t.attributes);for(let t,n=0;t=r[n];n++)if(e[t.nodeName]===t.nodeValue)return!0;return!1}function h(t,e,r,n,i,s){if(i(t)||r.indexOf(t.tagName)>-1||p(t,n))return s;if(e.indexOf(t.tagName)>-1&&s++,!t.childNodes||0===t.childNodes.length)return s;const a=o.toArray(t.childNodes);return Math.max.apply(null,a.map((function(t){return h(t,e,r,n,i,s)})))}function f(t){return u("fraction",t,["fraction"],e.nestingBarriers,{},s.LOCALE.FUNCTIONS.fracNestDepth)}function d(t,e,r){const n=f(t),o=Array(n).fill(e);return r&&o.push(r),o.join(s.LOCALE.MESSAGES.regexp.JOINER_FRAC)}function m(t,e,r){for(;t.parentNode;){const n=t.parentNode,o=n.parentNode;if(!o)break;const i=t.getAttribute&&t.getAttribute("role");("subscript"===o.tagName&&t===n.childNodes[1]||"tensor"===o.tagName&&i&&("leftsub"===i||"rightsub"===i))&&(e=r.sub+s.LOCALE.MESSAGES.regexp.JOINER_SUBSUPER+e),("superscript"===o.tagName&&t===n.childNodes[1]||"tensor"===o.tagName&&i&&("leftsuper"===i||"rightsuper"===i))&&(e=r.sup+s.LOCALE.MESSAGES.regexp.JOINER_SUBSUPER+e),t=o}return e.trim()}function y(t){return u("radical",t,["sqrt","root"],e.nestingBarriers,{})}function g(t,e,r){const n=y(t),o=b(t);return r=o?s.LOCALE.FUNCTIONS.combineRootIndex(r,o):r,1===n?r:s.LOCALE.FUNCTIONS.combineNestedRadical(e,s.LOCALE.FUNCTIONS.radicalNestDepth(n-1),r)}function b(t){const e="sqrt"===t.tagName?"2":i.evalXPath("children/*[1]",t)[0].textContent.trim();return s.LOCALE.MESSAGES.MSroots[e]||""}function v(t){return u("underscore",t,["underscore"],e.nestingBarriers,{},(function(t){return t.tagName&&"underscore"===t.tagName&&"underaccent"===t.childNodes[0].childNodes[1].getAttribute("role")}))}function _(t){return u("overscore",t,["overscore"],e.nestingBarriers,{},(function(t){return t.tagName&&"overscore"===t.tagName&&"overaccent"===t.childNodes[0].childNodes[1].getAttribute("role")}))}e.spaceoutText=function(t){return Array.from(t.textContent).join(" ")},e.spaceoutNodes=c,e.spaceoutNumber=function(t){return c(t,(function(t){t.textContent.match(/\W/)||(t.type="number")}))},e.spaceoutIdentifier=function(t){return c(t,(function(t){t.font="unknown",t.type="identifier"}))},e.nestingBarriers=["cases","cell","integral","line","matrix","multiline","overscore","root","row","sqrt","subscript","superscript","table","underscore","vector"],e.resetNestingDepth=function(t){return l={},[t]},e.getNestingDepth=u,e.containsAttr=p,e.computeNestingDepth_=h,e.fractionNestingDepth=f,e.nestedFraction=d,e.openingFractionVerbose=function(t){return d(t,s.LOCALE.MESSAGES.MS.START,s.LOCALE.MESSAGES.MS.FRAC_V)},e.closingFractionVerbose=function(t){return d(t,s.LOCALE.MESSAGES.MS.END,s.LOCALE.MESSAGES.MS.FRAC_V)},e.overFractionVerbose=function(t){return d(t,s.LOCALE.MESSAGES.MS.FRAC_OVER)},e.openingFractionBrief=function(t){return d(t,s.LOCALE.MESSAGES.MS.START,s.LOCALE.MESSAGES.MS.FRAC_B)},e.closingFractionBrief=function(t){return d(t,s.LOCALE.MESSAGES.MS.END,s.LOCALE.MESSAGES.MS.FRAC_B)},e.openingFractionSbrief=function(t){const e=f(t);return 1===e?s.LOCALE.MESSAGES.MS.FRAC_S:s.LOCALE.FUNCTIONS.combineNestedFraction(s.LOCALE.MESSAGES.MS.NEST_FRAC,s.LOCALE.FUNCTIONS.radicalNestDepth(e-1),s.LOCALE.MESSAGES.MS.FRAC_S)},e.closingFractionSbrief=function(t){const e=f(t);return 1===e?s.LOCALE.MESSAGES.MS.ENDFRAC:s.LOCALE.FUNCTIONS.combineNestedFraction(s.LOCALE.MESSAGES.MS.NEST_FRAC,s.LOCALE.FUNCTIONS.radicalNestDepth(e-1),s.LOCALE.MESSAGES.MS.ENDFRAC)},e.overFractionSbrief=function(t){const e=f(t);return 1===e?s.LOCALE.MESSAGES.MS.FRAC_OVER:s.LOCALE.FUNCTIONS.combineNestedFraction(s.LOCALE.MESSAGES.MS.NEST_FRAC,s.LOCALE.FUNCTIONS.radicalNestDepth(e-1),s.LOCALE.MESSAGES.MS.FRAC_OVER)},e.isSmallVulgarFraction=function(t){return s.LOCALE.FUNCTIONS.fracNestDepth(t)?[t]:[]},e.nestedSubSuper=m,e.subscriptVerbose=function(t){return m(t,s.LOCALE.MESSAGES.MS.SUBSCRIPT,{sup:s.LOCALE.MESSAGES.MS.SUPER,sub:s.LOCALE.MESSAGES.MS.SUB})},e.subscriptBrief=function(t){return m(t,s.LOCALE.MESSAGES.MS.SUB,{sup:s.LOCALE.MESSAGES.MS.SUP,sub:s.LOCALE.MESSAGES.MS.SUB})},e.superscriptVerbose=function(t){return m(t,s.LOCALE.MESSAGES.MS.SUPERSCRIPT,{sup:s.LOCALE.MESSAGES.MS.SUPER,sub:s.LOCALE.MESSAGES.MS.SUB})},e.superscriptBrief=function(t){return m(t,s.LOCALE.MESSAGES.MS.SUP,{sup:s.LOCALE.MESSAGES.MS.SUP,sub:s.LOCALE.MESSAGES.MS.SUB})},e.baselineVerbose=function(t){const e=m(t,"",{sup:s.LOCALE.MESSAGES.MS.SUPER,sub:s.LOCALE.MESSAGES.MS.SUB});return e?e.replace(new RegExp(s.LOCALE.MESSAGES.MS.SUB+"$"),s.LOCALE.MESSAGES.MS.SUBSCRIPT).replace(new RegExp(s.LOCALE.MESSAGES.MS.SUPER+"$"),s.LOCALE.MESSAGES.MS.SUPERSCRIPT):s.LOCALE.MESSAGES.MS.BASELINE},e.baselineBrief=function(t){return m(t,"",{sup:s.LOCALE.MESSAGES.MS.SUP,sub:s.LOCALE.MESSAGES.MS.SUB})||s.LOCALE.MESSAGES.MS.BASE},e.radicalNestingDepth=y,e.nestedRadical=g,e.getRootIndex=b,e.openingRadicalVerbose=function(t){return g(t,s.LOCALE.MESSAGES.MS.NESTED,s.LOCALE.MESSAGES.MS.STARTROOT)},e.closingRadicalVerbose=function(t){return g(t,s.LOCALE.MESSAGES.MS.NESTED,s.LOCALE.MESSAGES.MS.ENDROOT)},e.indexRadicalVerbose=function(t){return g(t,s.LOCALE.MESSAGES.MS.NESTED,s.LOCALE.MESSAGES.MS.ROOTINDEX)},e.openingRadicalBrief=function(t){return g(t,s.LOCALE.MESSAGES.MS.NEST_ROOT,s.LOCALE.MESSAGES.MS.STARTROOT)},e.closingRadicalBrief=function(t){return g(t,s.LOCALE.MESSAGES.MS.NEST_ROOT,s.LOCALE.MESSAGES.MS.ENDROOT)},e.indexRadicalBrief=function(t){return g(t,s.LOCALE.MESSAGES.MS.NEST_ROOT,s.LOCALE.MESSAGES.MS.ROOTINDEX)},e.openingRadicalSbrief=function(t){return g(t,s.LOCALE.MESSAGES.MS.NEST_ROOT,s.LOCALE.MESSAGES.MS.ROOT)},e.indexRadicalSbrief=function(t){return g(t,s.LOCALE.MESSAGES.MS.NEST_ROOT,s.LOCALE.MESSAGES.MS.INDEX)},e.underscoreNestingDepth=v,e.nestedUnderscript=function(t){const e=v(t);return Array(e).join(s.LOCALE.MESSAGES.MS.UNDER)+s.LOCALE.MESSAGES.MS.UNDERSCRIPT},e.overscoreNestingDepth=_,e.endscripts=function(t){return s.LOCALE.MESSAGES.MS.ENDSCRIPTS},e.nestedOverscript=function(t){const e=_(t);return Array(e).join(s.LOCALE.MESSAGES.MS.OVER)+s.LOCALE.MESSAGES.MS.OVERSCRIPT},e.determinantIsSimple=function(t){if("matrix"!==t.tagName||"determinant"!==t.getAttribute("role"))return[];const e=i.evalXPath("children/row/children/cell/children/*",t);for(let t,r=0;t=e[r];r++)if("number"!==t.tagName){if("identifier"===t.tagName){const e=t.getAttribute("role");if("latinletter"===e||"greekletter"===e||"otherletter"===e)continue}return[]}return[t]},e.generateBaselineConstraint=function(){const t=t=>t.map((t=>"ancestor::"+t)),e=t=>"not("+t+")",r=e(t(["subscript","superscript","tensor"]).join(" or ")),n=t(["relseq","multrel"]),o=t(["fraction","punctuation","fenced","sqrt","root"]);let i=[];for(let t,e=0;t=o[e];e++)i=i.concat(n.map((function(e){return t+"/"+e})));return[["ancestor::*/following-sibling::*",r,e(i.join(" | "))].join(" and ")]},e.removeParens=function(t){if(!t.childNodes.length||!t.childNodes[0].childNodes.length||!t.childNodes[0].childNodes[0].childNodes.length)return"";const e=t.childNodes[0].childNodes[0].childNodes[0].textContent;return e.match(/^\(.+\)$/)?e.slice(1,-1):e};const S=new Map([[3,"CSFleftsuperscript"],[4,"CSFleftsubscript"],[2,"CSFbaseline"],[1,"CSFrightsubscript"],[0,"CSFrightsuperscript"]]),M=new Map([[4,2],[3,3],[2,1],[1,4],[0,5]]);function O(t){const e=[];let r="",n="",o=parseInt(t,2);for(let t=0;t<5;t++){const i="children/*["+M.get(t)+"]";if(1&o){const e=S.get(t%5);r="[t] "+e+"Verbose; [n] "+i+";"+r,n="[t] "+e+"Brief; [n] "+i+";"+n}else e.unshift("name("+i+')="empty"');o>>=1}return[e,r,n]}e.generateTensorRules=function(t,e=!0){const r=["11111","11110","11101","11100","10111","10110","10101","10100","01111","01110","01101","01100"];for(let n,o=0;n=r[o];o++){let r="tensor"+n,[o,i,s]=O(n);t.defineRule(r,"default",i,"self::tensor",...o),e&&(t.defineRule(r,"brief",s,"self::tensor",...o),t.defineRule(r,"sbrief",s,"self::tensor",...o));const a=S.get(2);i+="; [t]"+a+"Verbose",s+="; [t]"+a+"Brief",r+="-baseline";const l="((.//*[not(*)])[last()]/@id)!=(((.//ancestor::fraction|ancestor::root|ancestor::sqrt|ancestor::cell|ancestor::line|ancestor::stree)[1]//*[not(*)])[last()]/@id)";t.defineRule(r,"default",i,"self::tensor",l,...o),e&&(t.defineRule(r,"brief",s,"self::tensor",l,...o),t.defineRule(r,"sbrief",s,"self::tensor",l,...o))}},e.smallRoot=function(t){let e=Object.keys(s.LOCALE.MESSAGES.MSroots).length;if(!e)return[];if(e++,!t.childNodes||0===t.childNodes.length||!t.childNodes[0].childNodes)return[];const r=t.childNodes[0].childNodes[0].textContent;if(!/^\d+$/.test(r))return[];const n=parseInt(r,10);return n>1&&n<=e?[t]:[]}},6922:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.implicitIterator=e.relationIterator=e.propagateNumber=e.checkParent_=e.NUMBER_INHIBITORS_=e.NUMBER_PROPAGATORS_=e.enlargeFence=e.indexRadical=e.closingRadical=e.openingRadical=e.radicalNestingDepth=e.nestedRadical=e.overBevelledFraction=e.overFraction=e.closingFraction=e.openingFraction=void 0;const n=r(7052),o=r(5740),i=r(5274),s=r(2105),a=r(5897),l=r(7630),c=r(9265),u=r(4356),p=r(1378);function h(t,e){const r=f(t);return 1===r?e:new Array(r).join(u.LOCALE.MESSAGES.MS.NESTED)+e}function f(t,e){const r=e||0;return t.parentNode?f(t.parentNode,"root"===t.tagName||"sqrt"===t.tagName?r+1:r):r}function d(t){const e="\u2820";if(1===t.length)return e+t;const r=t.split("");return r.every((function(t){return"\u2833"===t}))?e+r.join(e):t.slice(0,-1)+e+t.slice(-1)}function m(t,r){const n=t.parent;if(!n)return!1;const o=n.type;return-1!==e.NUMBER_PROPAGATORS_.indexOf(o)||"prefixop"===o&&"negative"===n.role&&!r.script||"prefixop"===o&&"geometry"===n.role||!("punctuated"!==o||r.enclosed&&"text"!==n.role)}function y(t,r){return t.childNodes.length?(-1!==e.NUMBER_INHIBITORS_.indexOf(t.type)&&(r.script=!0),"fenced"===t.type?(r.number=!1,r.enclosed=!0,["",r]):(m(t,r)&&(r.number=!0,r.enclosed=!1),["",r])):(m(t,r)&&(r.number=!0,r.script=!1,r.enclosed=!1),[r.number?"number":"",{number:!1,enclosed:r.enclosed,script:r.script}])}e.openingFraction=function(t){const e=p.fractionNestingDepth(t);return new Array(e).join(u.LOCALE.MESSAGES.MS.FRACTION_REPEAT)+u.LOCALE.MESSAGES.MS.FRACTION_START},e.closingFraction=function(t){const e=p.fractionNestingDepth(t);return new Array(e).join(u.LOCALE.MESSAGES.MS.FRACTION_REPEAT)+u.LOCALE.MESSAGES.MS.FRACTION_END},e.overFraction=function(t){const e=p.fractionNestingDepth(t);return new Array(e).join(u.LOCALE.MESSAGES.MS.FRACTION_REPEAT)+u.LOCALE.MESSAGES.MS.FRACTION_OVER},e.overBevelledFraction=function(t){const e=p.fractionNestingDepth(t);return new Array(e).join(u.LOCALE.MESSAGES.MS.FRACTION_REPEAT)+"\u2838"+u.LOCALE.MESSAGES.MS.FRACTION_OVER},e.nestedRadical=h,e.radicalNestingDepth=f,e.openingRadical=function(t){return h(t,u.LOCALE.MESSAGES.MS.STARTROOT)},e.closingRadical=function(t){return h(t,u.LOCALE.MESSAGES.MS.ENDROOT)},e.indexRadical=function(t){return h(t,u.LOCALE.MESSAGES.MS.ROOTINDEX)},e.enlargeFence=d,s.Grammar.getInstance().setCorrection("enlargeFence",d),e.NUMBER_PROPAGATORS_=["multirel","relseq","appl","row","line"],e.NUMBER_INHIBITORS_=["subscript","superscript","overscore","underscore"],e.checkParent_=m,e.propagateNumber=y,(0,l.register)(new c.SemanticVisitor("nemeth","number",y,{number:!0})),e.relationIterator=function(t,e){const r=t.slice(0);let s,l=!0;return s=t.length>0?i.evalXPath("../../content/*",t[0]):[],function(){const t=s.shift(),i=r.shift(),c=r[0],h=e?[n.AuditoryDescription.create({text:e},{translate:!0})]:[];if(!t)return h;const f=i?p.nestedSubSuper(i,"",{sup:u.LOCALE.MESSAGES.MS.SUPER,sub:u.LOCALE.MESSAGES.MS.SUB}):"",d=i&&"EMPTY"!==o.tagName(i)||l&&t.parentNode.parentNode&&t.parentNode.parentNode.previousSibling?[n.AuditoryDescription.create({text:"\u2800"+f},{})]:[],m=c&&"EMPTY"!==o.tagName(c)||!s.length&&t.parentNode.parentNode&&t.parentNode.parentNode.nextSibling?[n.AuditoryDescription.create({text:"\u2800"},{})]:[],y=a.default.evaluateNode(t);return l=!1,h.concat(d,y,m)}},e.implicitIterator=function(t,e){const r=t.slice(0);let s;return s=t.length>0?i.evalXPath("../../content/*",t[0]):[],function(){const t=r.shift(),i=r[0],a=s.shift(),l=e?[n.AuditoryDescription.create({text:e},{translate:!0})]:[];if(!a)return l;const c=t&&"NUMBER"===o.tagName(t),u=i&&"NUMBER"===o.tagName(i);return l.concat(c&&u&&"space"===a.getAttribute("role")?[n.AuditoryDescription.create({text:"\u2800"},{})]:[])}}},8437:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.ordinalPosition=e.vulgarFraction=e.wordCounter=e.ordinalCounter=void 0;const n=r(9536),o=r(5740),i=r(4356),s=r(4977);e.ordinalCounter=function(t,e){let r=0;return function(){return i.LOCALE.NUMBERS.numericOrdinal(++r)+" "+e}},e.wordCounter=function(t,e){let r=0;return function(){return i.LOCALE.NUMBERS.numberToOrdinal(++r,!1)+" "+e}},e.vulgarFraction=function(t){const e=(0,s.convertVulgarFraction)(t,i.LOCALE.MESSAGES.MS.FRAC_OVER);return e.convertible&&e.enumerator&&e.denominator?[new n.Span(i.LOCALE.NUMBERS.numberToWords(e.enumerator),{extid:t.childNodes[0].childNodes[0].getAttribute("extid"),separator:""}),new n.Span(i.LOCALE.NUMBERS.vulgarSep,{separator:""}),new n.Span(i.LOCALE.NUMBERS.numberToOrdinal(e.denominator,1!==e.enumerator),{extid:t.childNodes[0].childNodes[1].getAttribute("extid")})]:[new n.Span(e.content||"",{extid:t.getAttribute("extid")})]},e.ordinalPosition=function(t){const e=o.toArray(t.parentNode.childNodes);return i.LOCALE.NUMBERS.numericOrdinal(e.indexOf(t)+1).toString()}},9284:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.BrailleRules=e.OtherRules=e.PrefixRules=void 0;const n=r(1676),o=r(365),i=r(1378),s=r(6922),a=r(8437),l=r(7283);e.PrefixRules=function(){l.addStore("en.prefix.default","",{CSFordinalPosition:a.ordinalPosition})},e.OtherRules=function(){l.addStore("en.speech.chromevox","",{CTFnodeCounter:o.nodeCounter,CTFcontentIterator:o.contentIterator}),l.addStore("en.speech.emacspeak","en.speech.chromevox",{CQFvulgarFractionSmall:i.isSmallVulgarFraction,CSFvulgarFraction:a.vulgarFraction})},e.BrailleRules=function(){l.addStore("nemeth.braille.default",n.DynamicCstr.BASE_LOCALE+".speech.mathspeak",{CSFopenFraction:s.openingFraction,CSFcloseFraction:s.closingFraction,CSFoverFraction:s.overFraction,CSFoverBevFraction:s.overBevelledFraction,CSFopenRadical:s.openingRadical,CSFcloseRadical:s.closingRadical,CSFindexRadical:s.indexRadical,CSFsubscript:i.subscriptVerbose,CSFsuperscript:i.superscriptVerbose,CSFbaseline:i.baselineVerbose,CGFtensorRules:t=>i.generateTensorRules(t,!1),CTFrelationIterator:s.relationIterator,CTFimplicitIterator:s.implicitIterator})}},7599:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.init=e.INIT_=void 0;const n=r(5425),o=r(9577),i=r(9284);e.INIT_=!1,e.init=function(){e.INIT_||((0,o.MathspeakRules)(),(0,n.ClearspeakRules)(),(0,i.PrefixRules)(),(0,i.OtherRules)(),(0,i.BrailleRules)(),e.INIT_=!0)}},7283:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.getStore=e.addStore=e.funcStore=void 0;const n=r(1676);e.funcStore=new Map,e.addStore=function(t,r,n){const o={};if(r){const t=e.funcStore.get(r)||{};Object.assign(o,t)}e.funcStore.set(t,Object.assign(o,n))},e.getStore=function(t,r,o){return e.funcStore.get([t,r,o].join("."))||e.funcStore.get([n.DynamicCstr.DEFAULT_VALUES[n.Axis.LOCALE],r,o].join("."))||e.funcStore.get([n.DynamicCstr.BASE_LOCALE,r,o].join("."))||{}}},7598:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.oneLeft=e.leftMostUnit=e.rightMostUnit=e.unitMultipliers=void 0;const n=r(7052),o=r(5274),i=r(4356);e.unitMultipliers=function(t,e){const r=t;let o=0;return function(){const t=n.AuditoryDescription.create({text:a(r[o])&&l(r[o+1])?i.LOCALE.MESSAGES.unitTimes:""},{});return o++,[t]}};const s=["superscript","subscript","overscore","underscore"];function a(t){for(;t;){if("unit"===t.getAttribute("role"))return!0;const e=t.tagName,r=o.evalXPath("children/*",t);t=-1!==s.indexOf(e)?r[0]:r[r.length-1]}return!1}function l(t){for(;t;){if("unit"===t.getAttribute("role"))return!0;t=o.evalXPath("children/*",t)[0]}return!1}e.rightMostUnit=a,e.leftMostUnit=l,e.oneLeft=function(t){for(;t;){if("number"===t.tagName&&"1"===t.textContent)return[t];if("infixop"!==t.tagName||"multiplication"!==t.getAttribute("role")&&"implicit"!==t.getAttribute("role"))return[];t=o.evalXPath("children/*",t)[0]}return[]}},3284:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.AbstractWalker=void 0;const n=r(7052),o=r(8290),i=r(5740),s=r(4440),a=r(6828),l=r(8496),c=r(2298),u=r(4356),p=r(2105),h=r(5656),f=r(9552),d=r(9543),m=r(8504),y=r(7730),g=r(1214),b=r(179),v=r(1204),_=r(5274);class S{constructor(t,e,r,n){this.node=t,this.generator=e,this.highlighter=r,this.modifier=!1,this.keyMapping=new Map([[l.KeyCode.UP,this.up.bind(this)],[l.KeyCode.DOWN,this.down.bind(this)],[l.KeyCode.RIGHT,this.right.bind(this)],[l.KeyCode.LEFT,this.left.bind(this)],[l.KeyCode.TAB,this.repeat.bind(this)],[l.KeyCode.DASH,this.expand.bind(this)],[l.KeyCode.SPACE,this.depth.bind(this)],[l.KeyCode.HOME,this.home.bind(this)],[l.KeyCode.X,this.summary.bind(this)],[l.KeyCode.Z,this.detail.bind(this)],[l.KeyCode.V,this.virtualize.bind(this)],[l.KeyCode.P,this.previous.bind(this)],[l.KeyCode.U,this.undo.bind(this)],[l.KeyCode.LESS,this.previousRules.bind(this)],[l.KeyCode.GREATER,this.nextRules.bind(this)]]),this.cursors=[],this.xml_=null,this.rebuilt_=null,this.focus_=null,this.active_=!1,this.node.id?this.id=this.node.id:this.node.hasAttribute(S.SRE_ID_ATTR)?this.id=this.node.getAttribute(S.SRE_ID_ATTR):(this.node.setAttribute(S.SRE_ID_ATTR,S.ID_COUNTER.toString()),this.id=S.ID_COUNTER++),this.rootNode=v.getSemanticRoot(t),this.rootId=this.rootNode.getAttribute(c.Attribute.ID),this.xmlString_=n,this.moved=b.WalkerMoves.ENTER}getXml(){return this.xml_||(this.xml_=i.parseInput(this.xmlString_)),this.xml_}getRebuilt(){return this.rebuilt_||this.rebuildStree(),this.rebuilt_}isActive(){return this.active_}activate(){this.isActive()||(this.generator.start(),this.toggleActive_())}deactivate(){this.isActive()&&(b.WalkerState.setState(this.id,this.primaryId()),this.generator.end(),this.toggleActive_())}getFocus(t=!1){return this.focus_||(this.focus_=this.singletonFocus(this.rootId)),t&&this.updateFocus(),this.focus_}setFocus(t){this.focus_=t}getDepth(){return this.levels.depth()-1}isSpeech(){return this.generator.modality===c.Attribute.SPEECH}focusDomNodes(){return this.getFocus().getDomNodes()}focusSemanticNodes(){return this.getFocus().getSemanticNodes()}speech(){const t=this.focusDomNodes();if(!t.length)return"";const e=this.specialMove();if(null!==e)return e;switch(this.moved){case b.WalkerMoves.DEPTH:return this.depth_();case b.WalkerMoves.SUMMARY:return this.summary_();case b.WalkerMoves.DETAIL:return this.detail_();default:{const e=[],r=this.focusSemanticNodes();for(let n=0,o=t.length;n0}restoreState(){if(!this.highlighter)return;const t=b.WalkerState.getState(this.id);if(!t)return;let e=this.getRebuilt().nodeDict[t];const r=[];for(;e;)r.push(e.id),e=e.parent;for(r.pop();r.length>0;){this.down();const t=r.pop(),e=this.findFocusOnLevel(t);if(!e)break;this.setFocus(e)}this.moved=b.WalkerMoves.ENTER}updateFocus(){this.setFocus(y.Focus.factory(this.getFocus().getSemanticPrimary().id.toString(),this.getFocus().getSemanticNodes().map((t=>t.id.toString())),this.getRebuilt(),this.node))}rebuildStree(){this.rebuilt_=new g.RebuildStree(this.getXml()),this.rootId=this.rebuilt_.stree.root.id.toString(),this.generator.setRebuilt(this.rebuilt_),this.skeleton=h.SemanticSkeleton.fromTree(this.rebuilt_.stree),this.skeleton.populate(),this.focus_=this.singletonFocus(this.rootId),this.levels=this.initLevels(),d.connectMactions(this.node,this.getXml(),this.rebuilt_.xml)}previousLevel(){const t=this.getFocus().getDomPrimary();return t?v.getAttribute(t,c.Attribute.PARENT):this.getFocus().getSemanticPrimary().parent.id.toString()}nextLevel(){const t=this.getFocus().getDomPrimary();let e,r;if(t){e=v.splitAttribute(v.getAttribute(t,c.Attribute.CHILDREN)),r=v.splitAttribute(v.getAttribute(t,c.Attribute.CONTENT));const n=v.getAttribute(t,c.Attribute.TYPE),o=v.getAttribute(t,c.Attribute.ROLE);return this.combineContentChildren(n,o,r,e)}const n=t=>t.id.toString(),o=this.getRebuilt().nodeDict[this.primaryId()];return e=o.childNodes.map(n),r=o.contentNodes.map(n),0===e.length?[]:this.combineContentChildren(o.type,o.role,r,e)}singletonFocus(t){this.getRebuilt();const e=this.retrieveVisuals(t);return this.focusFromId(t,e)}retrieveVisuals(t){if(!this.skeleton)return[t];const e=parseInt(t,10),r=this.skeleton.subtreeNodes(e);if(!r.length)return[t];r.unshift(e);const n={},o=[];_.updateEvaluator(this.getXml());for(const t of r)n[t]||(o.push(t.toString()),n[t]=!0,this.subtreeIds(t,n));return o}subtreeIds(t,e){const r=_.evalXPath(`//*[@data-semantic-id="${t}"]`,this.getXml());_.evalXPath("*//@data-semantic-id",r[0]).forEach((t=>e[parseInt(t.textContent,10)]=!0))}focusFromId(t,e){return y.Focus.factory(t,e,this.getRebuilt(),this.node)}summary(){return this.moved=this.isSpeech()?b.WalkerMoves.SUMMARY:b.WalkerMoves.REPEAT,this.getFocus().clone()}detail(){return this.moved=this.isSpeech()?b.WalkerMoves.DETAIL:b.WalkerMoves.REPEAT,this.getFocus().clone()}specialMove(){return null}virtualize(t){return this.cursors.push({focus:this.getFocus(),levels:this.levels,undo:t||!this.cursors.length}),this.levels=this.levels.clone(),this.getFocus().clone()}previous(){const t=this.cursors.pop();return t?(this.levels=t.levels,t.focus):this.getFocus()}undo(){let t;do{t=this.cursors.pop()}while(t&&!t.undo);return t?(this.levels=t.levels,t.focus):this.getFocus()}update(t){this.generator.setOptions(t),(0,a.setup)(t).then((()=>f.generator("Tree").getSpeech(this.node,this.getXml())))}nextRules(){const t=this.generator.getOptions();return"speech"!==t.modality?this.getFocus():(s.DOMAIN_TO_STYLES[t.domain]=t.style,t.domain="mathspeak"===t.domain?"clearspeak":"mathspeak",t.style=s.DOMAIN_TO_STYLES[t.domain],this.update(t),this.moved=b.WalkerMoves.REPEAT,this.getFocus().clone())}nextStyle(t,e){if("mathspeak"===t){const t=["default","brief","sbrief"],r=t.indexOf(e);return-1===r?e:r>=t.length-1?t[0]:t[r+1]}if("clearspeak"===t){const t=m.ClearspeakPreferences.getLocalePreferences().en;if(!t)return"default";const r=m.ClearspeakPreferences.relevantPreferences(this.getFocus().getSemanticPrimary()),n=m.ClearspeakPreferences.findPreference(e,r),o=t[r].map((function(t){return t.split("_")[1]})),i=o.indexOf(n);if(-1===i)return e;const s=i>=o.length-1?o[0]:o[i+1];return m.ClearspeakPreferences.addPreference(e,r,s)}return e}previousRules(){const t=this.generator.getOptions();return"speech"!==t.modality?this.getFocus():(t.style=this.nextStyle(t.domain,t.style),this.update(t),this.moved=b.WalkerMoves.REPEAT,this.getFocus().clone())}refocus(){let t,e=this.getFocus();for(;!e.getNodes().length;){t=this.levels.peek();const r=this.up();if(!r)break;this.setFocus(r),e=this.getFocus(!0)}this.levels.push(t),this.setFocus(e)}toggleActive_(){this.active_=!this.active_}mergePrefix_(t,e=[]){const r=this.isSpeech()?this.prefix_():"";r&&t.unshift(r);const n=this.isSpeech()?this.postfix_():"";return n&&t.push(n),o.finalize(o.merge(e.concat(t)))}prefix_(){const t=this.getFocus().getDomNodes(),e=this.getFocus().getSemanticNodes();return t[0]?v.getAttribute(t[0],c.Attribute.PREFIX):d.retrievePrefix(e[0])}postfix_(){const t=this.getFocus().getDomNodes();return t[0]?v.getAttribute(t[0],c.Attribute.POSTFIX):""}depth_(){const t=p.Grammar.getInstance().getParameter("depth");p.Grammar.getInstance().setParameter("depth",!0);const e=this.getFocus().getDomPrimary(),r=this.expandable(e)?u.LOCALE.MESSAGES.navigate.EXPANDABLE:this.collapsible(e)?u.LOCALE.MESSAGES.navigate.COLLAPSIBLE:"",i=u.LOCALE.MESSAGES.navigate.LEVEL+" "+this.getDepth(),s=this.getFocus().getSemanticNodes(),a=d.retrievePrefix(s[0]),l=[new n.AuditoryDescription({text:i,personality:{}}),new n.AuditoryDescription({text:a,personality:{}}),new n.AuditoryDescription({text:r,personality:{}})];return p.Grammar.getInstance().setParameter("depth",t),o.finalize(o.markup(l))}actionable_(t){const e=null==t?void 0:t.parentNode;return e&&this.highlighter.isMactionNode(e)?e:null}summary_(){const t=this.getFocus().getSemanticPrimary().id.toString(),e=this.getRebuilt().xml.getAttribute("id")===t?this.getRebuilt().xml:i.querySelectorAllByAttrValue(this.getRebuilt().xml,"id",t)[0],r=d.retrieveSummary(e);return this.mergePrefix_([r])}detail_(){const t=this.getFocus().getSemanticPrimary().id.toString(),e=this.getRebuilt().xml.getAttribute("id")===t?this.getRebuilt().xml:i.querySelectorAllByAttrValue(this.getRebuilt().xml,"id",t)[0],r=e.getAttribute("alternative");e.removeAttribute("alternative");const n=d.computeMarkup(e),o=this.mergePrefix_([n]);return e.setAttribute("alternative",r),o}}e.AbstractWalker=S,S.ID_COUNTER=0,S.SRE_ID_ATTR="sre-explorer-id"},162:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DummyWalker=void 0;const n=r(3284);class o extends n.AbstractWalker{up(){return null}down(){return null}left(){return null}right(){return null}repeat(){return null}depth(){return null}home(){return null}getDepth(){return 0}initLevels(){return null}combineContentChildren(t,e,r,n){return[]}findFocusOnLevel(t){return null}}e.DummyWalker=o},7730:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.Focus=void 0;const n=r(1204);class o{constructor(t,e){this.nodes=t,this.primary=e,this.domNodes=[],this.domPrimary_=null,this.allNodes=[]}static factory(t,e,r,i){const s=t=>n.getBySemanticId(i,t),a=r.nodeDict,l=s(t),c=e.map(s),u=e.map((function(t){return a[t]})),p=new o(u,a[t]);return p.domNodes=c,p.domPrimary_=l,p.allNodes=o.generateAllVisibleNodes_(e,c,a,i),p}static generateAllVisibleNodes_(t,e,r,i){const s=t=>n.getBySemanticId(i,t);let a=[];for(let n=0,l=t.length;n=e.length?null:e[t]}depth(){return this.level_.length}clone(){const t=new r;return t.level_=this.level_.slice(0),t}toString(){let t="";for(let e,r=0;e=this.level_[r];r++)t+="\n"+e.map((function(t){return t.toString()}));return t}}e.Levels=r},1214:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.RebuildStree=void 0;const n=r(5740),o=r(2298),i=r(3588),s=r(6537),a=r(3308),l=r(5656),c=r(7075),u=r(4795),p=r(1204);class h{constructor(t){this.mathml=t,this.factory=new s.SemanticNodeFactory,this.nodeDict={},this.mmlRoot=p.getSemanticRoot(t),this.streeRoot=this.assembleTree(this.mmlRoot),this.stree=c.SemanticTree.fromNode(this.streeRoot,this.mathml),this.xml=this.stree.xml(),a.default.getInstance().setNodeFactory(this.factory)}static addAttributes(t,e,r){r&&1===e.childNodes.length&&e.childNodes[0].nodeType!==n.NodeType.TEXT_NODE&&u.addAttributes(t,e.childNodes[0]),u.addAttributes(t,e)}static textContent(t,e,r){if(!r&&e.textContent)return void(t.textContent=e.textContent);const n=p.splitAttribute(p.getAttribute(e,o.Attribute.OPERATOR));n.length>1&&(t.textContent=n[1])}static isPunctuated(t){return!l.SemanticSkeleton.simpleCollapseStructure(t)&&t[1]&&l.SemanticSkeleton.contentCollapseStructure(t[1])}getTree(){return this.stree}assembleTree(t){const e=this.makeNode(t),r=p.splitAttribute(p.getAttribute(t,o.Attribute.CHILDREN)),n=p.splitAttribute(p.getAttribute(t,o.Attribute.CONTENT));if(h.addAttributes(e,t,!(r.length||n.length)),0===n.length&&0===r.length)return h.textContent(e,t),e;if(n.length>0){const t=p.getBySemanticId(this.mathml,n[0]);t&&h.textContent(e,t,!0)}e.contentNodes=n.map((t=>this.setParent(t,e))),e.childNodes=r.map((t=>this.setParent(t,e)));const i=p.getAttribute(t,o.Attribute.COLLAPSED);return i?this.postProcess(e,i):e}makeNode(t){const e=p.getAttribute(t,o.Attribute.TYPE),r=p.getAttribute(t,o.Attribute.ROLE),n=p.getAttribute(t,o.Attribute.FONT),i=p.getAttribute(t,o.Attribute.ANNOTATION)||"",s=p.getAttribute(t,o.Attribute.ID),a=p.getAttribute(t,o.Attribute.EMBELLISHED),l=p.getAttribute(t,o.Attribute.FENCEPOINTER),c=this.createNode(parseInt(s,10));return c.type=e,c.role=r,c.font=n||"unknown",c.parseAnnotation(i),l&&(c.fencePointer=l),a&&(c.embellished=a),c}makePunctuation(t){const e=this.createNode(t);return e.updateContent((0,i.invisibleComma)()),e.role="dummy",e}makePunctuated(t,e,r){const n=this.createNode(e[0]);n.type="punctuated",n.embellished=t.embellished,n.fencePointer=t.fencePointer,n.role=r;const o=e.splice(1,1)[0].slice(1);n.contentNodes=o.map(this.makePunctuation.bind(this)),this.collapsedChildren_(e)}makeEmpty(t,e,r){const n=this.createNode(e);n.type="empty",n.embellished=t.embellished,n.fencePointer=t.fencePointer,n.role=r}makeIndex(t,e,r){if(h.isPunctuated(e))return this.makePunctuated(t,e,r),void(e=e[0]);l.SemanticSkeleton.simpleCollapseStructure(e)&&!this.nodeDict[e.toString()]&&this.makeEmpty(t,e,r)}postProcess(t,e){const r=l.SemanticSkeleton.fromString(e).array;if("subsup"===t.type){const e=this.createNode(r[1][0]);return e.type="subscript",e.role="subsup",t.type="superscript",e.embellished=t.embellished,e.fencePointer=t.fencePointer,this.makeIndex(t,r[1][2],"rightsub"),this.makeIndex(t,r[2],"rightsuper"),this.collapsedChildren_(r),t}if("subscript"===t.type)return this.makeIndex(t,r[2],"rightsub"),this.collapsedChildren_(r),t;if("superscript"===t.type)return this.makeIndex(t,r[2],"rightsuper"),this.collapsedChildren_(r),t;if("tensor"===t.type)return this.makeIndex(t,r[2],"leftsub"),this.makeIndex(t,r[3],"leftsuper"),this.makeIndex(t,r[4],"rightsub"),this.makeIndex(t,r[5],"rightsuper"),this.collapsedChildren_(r),t;if("punctuated"===t.type){if(h.isPunctuated(r)){const e=r.splice(1,1)[0].slice(1);t.contentNodes=e.map(this.makePunctuation.bind(this))}return t}if("underover"===t.type){const e=this.createNode(r[1][0]);return"overaccent"===t.childNodes[1].role?(e.type="overscore",t.type="underscore"):(e.type="underscore",t.type="overscore"),e.role="underover",e.embellished=t.embellished,e.fencePointer=t.fencePointer,this.collapsedChildren_(r),t}return t}createNode(t){const e=this.factory.makeNode(t);return this.nodeDict[t.toString()]=e,e}collapsedChildren_(t){const e=t=>{const r=this.nodeDict[t[0]];r.childNodes=[];for(let n=1,o=t.length;ne.getSemanticPrimary().id===t))}}e.SemanticWalker=i},9806:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.SyntaxWalker=void 0;const n=r(707),o=r(3284),i=r(9797);class s extends o.AbstractWalker{constructor(t,e,r,n){super(t,e,r,n),this.node=t,this.generator=e,this.highlighter=r,this.levels=null,this.restoreState()}initLevels(){const t=new i.Levels;return t.push([this.primaryId()]),t}up(){super.up();const t=this.previousLevel();return t?(this.levels.pop(),this.singletonFocus(t)):null}down(){super.down();const t=this.nextLevel();if(0===t.length)return null;const e=this.singletonFocus(t[0]);return e&&this.levels.push(t),e}combineContentChildren(t,e,r,o){switch(t){case"relseq":case"infixop":case"multirel":return(0,n.interleaveLists)(o,r);case"prefixop":return r.concat(o);case"postfixop":return o.concat(r);case"matrix":case"vector":case"fenced":return o.unshift(r[0]),o.push(r[1]),o;case"cases":return o.unshift(r[0]),o;case"punctuated":return"text"===e?(0,n.interleaveLists)(o,r):o;case"appl":return[o[0],r[0],o[1]];case"root":return[o[1],o[0]];default:return o}}left(){super.left();const t=this.levels.indexOf(this.primaryId());if(null===t)return null;const e=this.levels.get(t-1);return e?this.singletonFocus(e):null}right(){super.right();const t=this.levels.indexOf(this.primaryId());if(null===t)return null;const e=this.levels.get(t+1);return e?this.singletonFocus(e):null}findFocusOnLevel(t){return this.singletonFocus(t.toString())}focusDomNodes(){return[this.getFocus().getDomPrimary()]}focusSemanticNodes(){return[this.getFocus().getSemanticPrimary()]}}e.SyntaxWalker=s},1799:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.TableWalker=void 0;const n=r(5740),o=r(8496),i=r(9806),s=r(179);class a extends i.SyntaxWalker{constructor(t,e,r,n){super(t,e,r,n),this.node=t,this.generator=e,this.highlighter=r,this.firstJump=null,this.key_=null,this.row_=0,this.currentTable_=null,this.keyMapping.set(o.KeyCode.ZERO,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.ONE,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.TWO,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.THREE,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.FOUR,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.FIVE,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.SIX,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.SEVEN,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.EIGHT,this.jumpCell.bind(this)),this.keyMapping.set(o.KeyCode.NINE,this.jumpCell.bind(this))}move(t){this.key_=t;const e=super.move(t);return this.modifier=!1,e}up(){return this.moved=s.WalkerMoves.UP,this.eligibleCell_()?this.verticalMove_(!1):super.up()}down(){return this.moved=s.WalkerMoves.DOWN,this.eligibleCell_()?this.verticalMove_(!0):super.down()}jumpCell(){if(!this.isInTable_()||null===this.key_)return this.getFocus();if(this.moved===s.WalkerMoves.ROW){this.moved=s.WalkerMoves.CELL;const t=this.key_-o.KeyCode.ZERO;return this.isLegalJump_(this.row_,t)?this.jumpCell_(this.row_,t):this.getFocus()}const t=this.key_-o.KeyCode.ZERO;return t>this.currentTable_.childNodes.length?this.getFocus():(this.row_=t,this.moved=s.WalkerMoves.ROW,this.getFocus().clone())}undo(){const t=super.undo();return t===this.firstJump&&(this.firstJump=null),t}eligibleCell_(){const t=this.getFocus().getSemanticPrimary();return this.modifier&&"cell"===t.type&&-1!==a.ELIGIBLE_CELL_ROLES.indexOf(t.role)}verticalMove_(t){const e=this.previousLevel();if(!e)return null;const r=this.getFocus(),n=this.levels.indexOf(this.primaryId()),o=this.levels.pop(),i=this.levels.indexOf(e),s=this.levels.get(t?i+1:i-1);if(!s)return this.levels.push(o),null;this.setFocus(this.singletonFocus(s));const a=this.nextLevel();return a[n]?(this.levels.push(a),this.singletonFocus(a[n])):(this.setFocus(r),this.levels.push(o),null)}jumpCell_(t,e){this.firstJump?this.virtualize(!1):(this.firstJump=this.getFocus(),this.virtualize(!0));const r=this.currentTable_.id.toString();let n;do{n=this.levels.pop()}while(-1===n.indexOf(r));this.levels.push(n),this.setFocus(this.singletonFocus(r)),this.levels.push(this.nextLevel());const o=this.currentTable_.childNodes[t-1];return this.setFocus(this.singletonFocus(o.id.toString())),this.levels.push(this.nextLevel()),this.singletonFocus(o.childNodes[e-1].id.toString())}isLegalJump_(t,e){const r=n.querySelectorAllByAttrValue(this.getRebuilt().xml,"id",this.currentTable_.id.toString())[0];if(!r||r.hasAttribute("alternative"))return!1;const o=this.currentTable_.childNodes[t-1];if(!o)return!1;const i=n.querySelectorAllByAttrValue(r,"id",o.id.toString())[0];return!(!i||i.hasAttribute("alternative"))&&!(!o||!o.childNodes[e-1])}isInTable_(){let t=this.getFocus().getSemanticPrimary();for(;t;){if(-1!==a.ELIGIBLE_TABLE_TYPES.indexOf(t.type))return this.currentTable_=t,!0;t=t.parent}return!1}}e.TableWalker=a,a.ELIGIBLE_CELL_ROLES=["determinant","rowvector","binomial","squarematrix","multiline","matrix","vector","cases","table"],a.ELIGIBLE_TABLE_TYPES=["multiline","matrix","vector","cases","table"]},179:function(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WalkerState=e.WalkerMoves=void 0,function(t){t.UP="up",t.DOWN="down",t.LEFT="left",t.RIGHT="right",t.REPEAT="repeat",t.DEPTH="depth",t.ENTER="enter",t.EXPAND="expand",t.HOME="home",t.SUMMARY="summary",t.DETAIL="detail",t.ROW="row",t.CELL="cell"}(e.WalkerMoves||(e.WalkerMoves={}));class r{static resetState(t){delete r.STATE[t]}static setState(t,e){r.STATE[t]=e}static getState(t){return r.STATE[t]}}e.WalkerState=r,r.STATE={}},3362:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.walkerMapping_=e.walker=void 0;const n=r(162),o=r(6295),i=r(9806),s=r(1799);e.walker=function(t,r,n,o,i){return(e.walkerMapping_[t.toLowerCase()]||e.walkerMapping_.dummy)(r,n,o,i)},e.walkerMapping_={dummy:(t,e,r,o)=>new n.DummyWalker(t,e,r,o),semantic:(t,e,r,n)=>new o.SemanticWalker(t,e,r,n),syntax:(t,e,r,n)=>new i.SyntaxWalker(t,e,r,n),table:(t,e,r,n)=>new s.TableWalker(t,e,r,n)}},1204:function(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.getBySemanticId=e.getSemanticRoot=e.getAttribute=e.splitAttribute=void 0;const n=r(5740),o=r(2298);e.splitAttribute=function(t){return t?t.split(/,/):[]},e.getAttribute=function(t,e){return t.getAttribute(e)},e.getSemanticRoot=function(t){if(t.hasAttribute(o.Attribute.TYPE)&&!t.hasAttribute(o.Attribute.PARENT))return t;const e=n.querySelectorAllByAttr(t,o.Attribute.TYPE);for(let t,r=0;t=e[r];r++)if(!t.hasAttribute(o.Attribute.PARENT))return t;return t},e.getBySemanticId=function(t,e){return t.getAttribute(o.Attribute.ID)===e?t:n.querySelectorAllByAttrValue(t,o.Attribute.ID,e)[0]}}},__webpack_module_cache__={};function __webpack_require__(t){var e=__webpack_module_cache__[t];if(void 0!==e)return e.exports;var r=__webpack_module_cache__[t]={exports:{}};return __webpack_modules__[t].call(r.exports,r,r.exports,__webpack_require__),r.exports}__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();var __webpack_exports__={};!function(){var t=__webpack_require__(9515),e=__webpack_require__(3282),r=__webpack_require__(235),n=__webpack_require__(265),o=__webpack_require__(2388);function i(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=new Array(e);r0&&void 0!==arguments[0]?arguments[0]:[],e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(MathJax.startup){e&&(MathJax.startup.registerConstructor("tex",MathJax._.input.tex_ts.TeX),MathJax.startup.useInput("tex")),MathJax.config.tex||(MathJax.config.tex={});var r=MathJax.config.tex.packages;MathJax.config.tex.packages=t,r&&(0,xt.insert)(MathJax.config.tex,{packages:r})}}(["base","ams","newcommand","noundefined","require","autoload","configmacros"]);var pe=__webpack_require__(2892),he=__webpack_require__(625),fe=__webpack_require__(2769);MathJax.loader&&MathJax.loader.checkVersion("input/mml",e.VERSION,"input"),(0,t.combineWithMathJax)({_:{input:{mathml_ts:pe,mathml:{FindMathML:he,MathMLCompile:fe}}}}),MathJax.startup&&(MathJax.startup.registerConstructor("mml",pe.MathML),MathJax.startup.useInput("mml")),MathJax.loader&&MathJax.loader.pathFilters.add((function(t){return t.name=t.name.replace(/\/util\/entities\/.*?\.js/,"/input/mml/entities.js"),!0}));var de=__webpack_require__(50),me=__webpack_require__(8042),ye=__webpack_require__(8270),ge=__webpack_require__(6797),be=__webpack_require__(5355),ve=__webpack_require__(9261),_e=__webpack_require__(9086),Se=__webpack_require__(95),Me=__webpack_require__(1148),Oe=__webpack_require__(8102),xe=__webpack_require__(804),Ee=__webpack_require__(8147),Ae=__webpack_require__(2275),Ce=__webpack_require__(9063),Te=__webpack_require__(6911),Ne=__webpack_require__(1653),we=__webpack_require__(6781),Le=__webpack_require__(6460),Ie=__webpack_require__(6287),Pe=__webpack_require__(5964),Re=__webpack_require__(8776),ke=__webpack_require__(4798),je=__webpack_require__(4597),Be=__webpack_require__(2970),De=__webpack_require__(5610),Fe=__webpack_require__(4300),He=__webpack_require__(8002),Ue=__webpack_require__(7056),Xe=__webpack_require__(1259),Ve=__webpack_require__(3571),qe=__webpack_require__(6590),We=__webpack_require__(8650),Ge=__webpack_require__(421),ze=__webpack_require__(5884),Je=__webpack_require__(5552),Ke=__webpack_require__(3055),$e=__webpack_require__(7519),Ye=__webpack_require__(4420),Ze=__webpack_require__(9800),Qe=__webpack_require__(1160),tr=__webpack_require__(1956),er=__webpack_require__(7490),rr=__webpack_require__(7313),nr=__webpack_require__(7555),or=__webpack_require__(2688),ir=__webpack_require__(5636),sr=__webpack_require__(5723),ar=__webpack_require__(8009),lr=__webpack_require__(5023),cr=__webpack_require__(7096),ur=__webpack_require__(6898),pr=__webpack_require__(6991),hr=__webpack_require__(8411),fr=__webpack_require__(4126),dr=__webpack_require__(258),mr=__webpack_require__(4093),yr=__webpack_require__(905),gr=__webpack_require__(6237),br=__webpack_require__(5164),vr=__webpack_require__(6319),_r=__webpack_require__(5766),Sr=__webpack_require__(1971),Mr=__webpack_require__(167),Or=__webpack_require__(5806);MathJax.loader&&MathJax.loader.checkVersion("output/chtml",e.VERSION,"output"),(0,t.combineWithMathJax)({_:{output:{chtml_ts:de,chtml:{FontData:me,Notation:ye,Usage:ge,Wrapper:be,WrapperFactory:ve,Wrappers_ts:_e,Wrappers:{TeXAtom:Se,TextNode:Me,maction:Oe,math:xe,menclose:Ee,mfenced:Ae,mfrac:Ce,mglyph:Te,mi:Ne,mmultiscripts:we,mn:Le,mo:Ie,mpadded:Pe,mroot:Re,mrow:ke,ms:je,mspace:Be,msqrt:De,msubsup:Fe,mtable:He,mtd:Ue,mtext:Xe,mtr:Ve,munderover:qe,scriptbase:We,semantics:Ge}},common:{FontData:ze,Notation:Je,OutputJax:Ke,Wrapper:$e,WrapperFactory:Ye,Wrappers:{TeXAtom:Ze,TextNode:Qe,maction:tr,math:er,menclose:rr,mfenced:nr,mfrac:or,mglyph:ir,mi:sr,mmultiscripts:ar,mn:lr,mo:cr,mpadded:ur,mroot:pr,mrow:hr,ms:fr,mspace:dr,msqrt:mr,msubsup:yr,mtable:gr,mtd:br,mtext:vr,mtr:_r,munderover:Sr,scriptbase:Mr,semantics:Or}}}}}),MathJax.loader&&(0,t.combineDefaults)(MathJax.config.loader,"output/chtml",{checkReady:function(){return MathJax.loader.load("output/chtml/fonts/tex")}}),MathJax.startup&&(MathJax.startup.registerConstructor("chtml",de.CHTML),MathJax.startup.useOutput("chtml"));var xr=__webpack_require__(2760),Er=__webpack_require__(4005),Ar=__webpack_require__(1015),Cr=__webpack_require__(6555),Tr=__webpack_require__(2183),Nr=__webpack_require__(3490),wr=__webpack_require__(9056),Lr=__webpack_require__(3019),Ir=__webpack_require__(2713),Pr=__webpack_require__(7517),Rr=__webpack_require__(4182),kr=__webpack_require__(2679),jr=__webpack_require__(5469),Br=__webpack_require__(775),Dr=__webpack_require__(9551),Fr=__webpack_require__(6530),Hr=__webpack_require__(4409),Ur=__webpack_require__(5292),Xr=__webpack_require__(3980),Vr=__webpack_require__(1103),qr=__webpack_require__(9124),Wr=__webpack_require__(6001),Gr=__webpack_require__(3696),zr=__webpack_require__(9587),Jr=__webpack_require__(8348),Kr=__webpack_require__(1376),$r=__webpack_require__(1439),Yr=__webpack_require__(331),Zr=__webpack_require__(4886),Qr=__webpack_require__(4471),tn=__webpack_require__(5181),en=__webpack_require__(3526),rn=__webpack_require__(5649),nn=__webpack_require__(7153),on=__webpack_require__(5745),sn=__webpack_require__(1411),an=__webpack_require__(6384),ln=__webpack_require__(6041),cn=__webpack_require__(8199),un=__webpack_require__(9848),pn=__webpack_require__(7906),hn=__webpack_require__(2644),fn=__webpack_require__(4926);if(MathJax.loader&&MathJax.loader.checkVersion("output/chtml/fonts/tex",e.VERSION,"chtml-font"),(0,t.combineWithMathJax)({_:{output:{chtml:{fonts:{tex_ts:xr,tex:{"bold-italic":Er,bold:Ar,"fraktur-bold":Cr,fraktur:Tr,italic:Nr,largeop:wr,monospace:Lr,normal:Ir,"sans-serif-bold-italic":Pr,"sans-serif-bold":Rr,"sans-serif-italic":kr,"sans-serif":jr,smallop:Br,"tex-calligraphic-bold":Dr,"tex-size3":Fr,"tex-size4":Hr,"tex-variant":Ur}}},common:{fonts:{tex:{"bold-italic":Xr,bold:Vr,delimiters:qr,"double-struck":Wr,"fraktur-bold":Gr,fraktur:zr,italic:Jr,largeop:Kr,monospace:$r,normal:Yr,"sans-serif-bold-italic":Zr,"sans-serif-bold":Qr,"sans-serif-italic":tn,"sans-serif":en,"script-bold":rn,script:nn,smallop:on,"tex-calligraphic-bold":sn,"tex-calligraphic":an,"tex-mathit":ln,"tex-oldstyle-bold":cn,"tex-oldstyle":un,"tex-size3":pn,"tex-size4":hn,"tex-variant":fn}}}}}}),MathJax.startup){(0,t.combineDefaults)(MathJax.config,"chtml",{fontURL:n.Package.resolvePath("output/chtml/fonts/woff-v2",!1)});var dn=(0,xt.selectOptionsFromKeys)(MathJax.config.chtml||{},xr.TeXFont.OPTIONS);(0,t.combineDefaults)(MathJax.config,"chtml",{font:new xr.TeXFont(dn)})}var mn=__webpack_require__(5865),yn=__webpack_require__(8310),gn=__webpack_require__(4001),bn=__webpack_require__(473),vn=__webpack_require__(4414);MathJax.loader&&MathJax.loader.checkVersion("ui/menu",e.VERSION,"ui"),(0,t.combineWithMathJax)({_:{ui:{menu:{MJContextMenu:mn,Menu:yn,MenuHandler:gn,MmlVisitor:bn,SelectableInfo:vn}}}}),MathJax.startup&&"undefined"!=typeof window&&MathJax.startup.extendHandler((function(t){return(0,gn.MenuHandler)(t)}),20);var _n=__webpack_require__(351);function Sn(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=new Array(e);r +# SPDX-License-Identifier: BSD-3-Clause +"""Render project reports as LaTeX source bundles.""" + +from __future__ import annotations + +import csv +import pathlib +import shutil + +from jinja2 import Environment +from jinja2 import PackageLoader +from jinja2 import select_autoescape + +from easydiffraction.report.fit_plot import fit_bragg_tick_styles +from easydiffraction.report.fit_plot import fit_plot_axis_styles +from easydiffraction.report.fit_plot import fit_plot_geometry +from easydiffraction.report.fit_plot import fit_plot_ranges +from easydiffraction.report.fit_plot import fit_plot_styles +from easydiffraction.report.fit_plot import fit_scatter_geometry +from easydiffraction.report.fit_plot import fit_scatter_ranges +from easydiffraction.report.fit_plot import fit_scatter_style +from easydiffraction.report.style import report_style_context + +_TEMPLATE_NAME = 'tex/report.tex.j2' +_FIGURE_TEMPLATE_NAME = 'tex/figure.tex.j2' +_FIGURE_SC_TEMPLATE_NAME = 'tex/figure_sc.tex.j2' +_TEX_SPECIAL_CHARS = { + '\\': r'\textbackslash{}', + '&': r'\&', + '%': r'\%', + '$': r'\$', + '#': r'\#', + '_': r'\_', + '{': r'\{', + '}': r'\}', + '~': r'\textasciitilde{}', + '^': r'\textasciicircum{}', +} +_FIT_X_FIELD_TAGS = { + 'two_theta': '_pd_proc.2theta_scan', + 'time_of_flight': '_pd_meas.time_of_flight', + 'd_spacing': '_pd_proc.d_spacing', + 'x': '_pd_proc.r', + 'r': '_pd_proc.r', +} +_FIT_CSV_FIELD_TAGS = ( + ('point_id', '_pd_data.point_id'), + ('d_spacing', '_pd_proc.d_spacing'), + ('intensity_meas', '_pd_meas.intensity_total'), + ('intensity_meas_su', '_pd_meas.intensity_total_su'), + ('intensity_calc', '_pd_calc.intensity_total'), + ('intensity_bkg', '_pd_calc.intensity_bkg'), + ('calc_status', '_pd_data.refinement_status'), +) +_REFLN_CSV_FIELD_TAGS = ( + ('id', '_refln.id'), + ('phase_id', '_refln.phase_id'), + ('d_spacing', '_refln.d_spacing'), + ('sin_theta_over_lambda', '_refln.sin_theta_over_lambda'), + ('index_h', '_refln.index_h'), + ('index_k', '_refln.index_k'), + ('index_l', '_refln.index_l'), + ('f_calc', '_refln.f_calc'), + ('f_squared_calc', '_refln.f_squared_calc'), + ('two_theta', '_refln.two_theta'), + ('time_of_flight', '_refln.time_of_flight'), +) + + +def tex_report_path( + project: object, + path: str | pathlib.Path | None = None, +) -> pathlib.Path: + """ + Return the target TeX report path for a project. + + Parameters + ---------- + project : object + Project instance. + path : str | pathlib.Path | None, default=None + Explicit report path. + + Returns + ------- + pathlib.Path + Resolved report path. + + Raises + ------ + FileNotFoundError + If no path is supplied and the project has not been saved. + """ + if path is not None: + return pathlib.Path(path) + + project_path = getattr(getattr(project, 'info', None), 'path', None) + if project_path is None: + msg = 'Project has no saved path. Save the project first.' + raise FileNotFoundError(msg) + + project_name = getattr(project, 'name', 'project') + return pathlib.Path(project_path) / 'reports' / 'tex' / f'{project_name}.tex' + + +def render_tex_report(context: dict[str, object]) -> str: + """ + Render a report data context as LaTeX. + + Parameters + ---------- + context : dict[str, object] + Data returned by ``Report.data_context()``. + + Returns + ------- + str + Complete LaTeX document. + """ + template_context = dict(context) + template_context['report_style'] = report_style_context() + template_context['tex'] = _tex_context( + context, + fit_csv_paths=_fit_csv_paths(context), + fit_figure_paths=_fit_figure_paths(context), + ) + return _environment().get_template(_TEMPLATE_NAME).render(**template_context) + + +def save_tex_report( + project: object, + context: dict[str, object], + *, + path: str | pathlib.Path | None = None, +) -> pathlib.Path: + """ + Write a TeX report bundle. + + Parameters + ---------- + project : object + Project instance. + context : dict[str, object] + Data returned by ``Report.data_context()``. + path : str | pathlib.Path | None, default=None + Explicit report path. + + Returns + ------- + pathlib.Path + Path of the written main TeX document. + """ + output_path = tex_report_path(project, path) + tex_dir = output_path.parent + + _prepare_tex_bundle(tex_dir) + + template_context = dict(context) + template_context['report_style'] = report_style_context() + fit_asset_paths = _write_fit_assets(project, context, tex_dir) + template_context['tex'] = _tex_context( + context, + fit_csv_paths=fit_asset_paths['csv'], + fit_figure_paths=fit_asset_paths['figure'], + ) + output_path.write_text( + _render_prepared_context(template_context), + encoding='utf-8', + ) + return output_path + + +def _render_prepared_context(context: dict[str, object]) -> str: + """Render a context that already contains TeX asset paths.""" + return _environment().get_template(_TEMPLATE_NAME).render(**context) + + +def _environment() -> Environment: + """Return the Jinja environment for TeX report templates.""" + environment = Environment( + loader=PackageLoader('easydiffraction.report', 'templates'), + autoescape=select_autoescape( + enabled_extensions=(), + default_for_string=False, + default=False, + ), + trim_blocks=True, + lstrip_blocks=True, + ) + environment.filters['tex'] = _tex_escape + environment.filters['tex_axis_label'] = _tex_axis_label + environment.filters['tex_markup'] = _tex_markup + environment.filters['tex_number'] = _tex_number + environment.filters['tex_unit'] = _tex_unit + return environment + + +def _prepare_tex_bundle(tex_dir: pathlib.Path) -> None: + """Remove managed bundle directories before writing TeX assets.""" + tex_dir.mkdir(parents=True, exist_ok=True) + for dirname in ('data', 'styles', 'figures'): + path = tex_dir / dirname + if path.exists(): + shutil.rmtree(path) + + +def _write_fit_assets( + project: object, + context: dict[str, object], + out_dir: pathlib.Path, +) -> dict[str, dict[str, str]]: + """Write fit-data CSV and figure TeX files.""" + csv_paths: dict[str, str] = {} + figure_paths: dict[str, str] = {} + project_experiments = _project_experiments_by_id(project) + for experiment in _experiment_contexts(context): + fit_data = experiment.get('fit_data') + if fit_data is None: + continue + experiment_id = str(experiment.get('id') or 'experiment') + source_experiment = project_experiments.get(experiment_id) + if _is_scatter_fit_data(fit_data): + csv_path = _write_fit_scatter_csv(experiment_id, fit_data, out_dir) + figure_path = _write_fit_scatter_tex( + experiment=experiment, + csv_path=csv_path, + out_dir=out_dir, + ) + else: + csv_path = _write_fit_csv( + experiment_id, + experiment, + source_experiment, + fit_data, + out_dir, + ) + bragg_csvs = _write_bragg_csvs( + experiment_id, + experiment, + source_experiment, + fit_data, + out_dir, + ) + figure_path = _write_fit_figure_tex( + experiment=experiment, + csv_path=csv_path, + bragg_csvs=bragg_csvs, + out_dir=out_dir, + ) + csv_paths[experiment_id] = f'data/{csv_path.name}' + figure_paths[experiment_id] = f'data/{figure_path.stem}.pdf' + return {'csv': csv_paths, 'figure': figure_paths} + + +def _is_scatter_fit_data(fit_data: dict[str, object]) -> bool: + """Return whether fit data is a single-crystal agreement scatter.""" + x_data = fit_data.get('x') or {} + return x_data.get('name') == 'intensity_calc' + + +def _tex_context( + context: dict[str, object], + *, + fit_csv_paths: dict[str, str], + fit_figure_paths: dict[str, str], +) -> dict[str, object]: + """Return TeX-specific render context.""" + return { + 'fit_csv_paths': fit_csv_paths, + 'fit_figure_paths': fit_figure_paths, + 'fit_bragg_tick_styles': fit_bragg_tick_styles(), + 'fit_plot_ranges': _fit_plot_ranges(context), + 'fit_plot_styles': fit_plot_styles(), + } + + +def _fit_plot_ranges(context: dict[str, object]) -> dict[str, dict[str, float]]: + """Return fit-figure axis ranges by experiment id.""" + ranges = {} + for experiment in _experiment_contexts(context): + fit_data = experiment.get('fit_data') + if fit_data is None: + continue + if _is_scatter_fit_data(fit_data): + continue + experiment_id = str(experiment.get('id') or 'experiment') + ranges[experiment_id] = fit_plot_ranges(fit_data) + return ranges + + +def _write_fit_csv( + expt_id: str, + experiment: dict[str, object], + source_experiment: object | None, + fit_data: dict[str, object], + out_dir: pathlib.Path, +) -> pathlib.Path: + """Write one fit-data CSV file under ``out_dir / 'data'``.""" + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + csv_path = data_dir / _fit_csv_filename(expt_id) + columns = _fit_csv_columns(expt_id, experiment, source_experiment, fit_data) + _write_csv(csv_path, expt_id, columns) + return csv_path + + +def _write_fit_figure_tex( + *, + experiment: dict[str, object], + csv_path: pathlib.Path, + bragg_csvs: dict[str, dict[str, str]], + out_dir: pathlib.Path, +) -> pathlib.Path: + """Write one standalone pgfplots TeX figure.""" + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + experiment_id = str(experiment.get('id') or 'experiment') + fit_data = experiment['fit_data'] + figure_path = data_dir / f'{_safe_asset_stem(experiment_id)}.tex' + template_context = { + 'experiment': experiment, + 'fit_data': fit_data, + 'csv_filename': csv_path.name, + 'fit_csv': _fit_csv_plot_columns(experiment, fit_data), + 'bragg_tick_sources': _bragg_tick_sources(fit_data, bragg_csvs), + 'geometry': fit_plot_geometry(fit_data), + 'ranges': fit_plot_ranges(fit_data), + 'axis_styles': fit_plot_axis_styles(), + 'styles': fit_plot_styles(), + 'bragg_styles': fit_bragg_tick_styles(), + } + figure_path.write_text( + _environment() + .get_template(_FIGURE_TEMPLATE_NAME) + .render( + **template_context, + ), + encoding='utf-8', + ) + return figure_path + + +def _write_fit_scatter_csv( + expt_id: str, + fit_data: dict[str, object], + out_dir: pathlib.Path, +) -> pathlib.Path: + """Write the single-crystal agreement-scatter CSV file.""" + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + csv_path = data_dir / _fit_csv_filename(expt_id) + meas = fit_data['series']['meas'] + calc_values = list(fit_data['x']['values']) + su = meas.get('su') + su_values = list(su) if su is not None else [0.0] * len(calc_values) + columns = [ + ('icalc', calc_values), + ('imeas', list(meas['values'])), + ('imeas_su', su_values), + ] + _write_csv(csv_path, expt_id, columns) + return csv_path + + +def _write_fit_scatter_tex( + *, + experiment: dict[str, object], + csv_path: pathlib.Path, + out_dir: pathlib.Path, +) -> pathlib.Path: + """Write one standalone single-crystal scatter TeX figure.""" + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + experiment_id = str(experiment.get('id') or 'experiment') + fit_data = experiment['fit_data'] + figure_path = data_dir / f'{_safe_asset_stem(experiment_id)}.tex' + template_context = { + 'experiment': experiment, + 'fit_data': fit_data, + 'csv_filename': csv_path.name, + 'fit_csv': {'x': 'icalc', 'meas': 'imeas', 'meas_su': 'imeas_su'}, + 'geometry': fit_scatter_geometry(), + 'ranges': fit_scatter_ranges(fit_data), + 'axis_styles': fit_plot_axis_styles(), + 'style': fit_scatter_style(), + } + figure_path.write_text( + _environment().get_template(_FIGURE_SC_TEMPLATE_NAME).render(**template_context), + encoding='utf-8', + ) + return figure_path + + +def _fit_csv_paths(context: dict[str, object]) -> dict[str, str]: + """Return expected fit-data CSV paths for TeX rendering.""" + paths = {} + for experiment in _experiment_contexts(context): + if experiment.get('fit_data') is None: + continue + experiment_id = str(experiment.get('id') or 'experiment') + paths[experiment_id] = f'data/{_fit_csv_filename(experiment_id)}' + return paths + + +def _fit_figure_paths(context: dict[str, object]) -> dict[str, str]: + """Return expected fit-figure PDF paths for TeX rendering.""" + paths = {} + for experiment in _experiment_contexts(context): + if experiment.get('fit_data') is None: + continue + experiment_id = str(experiment.get('id') or 'experiment') + paths[experiment_id] = f'data/{_safe_asset_stem(experiment_id)}.pdf' + return paths + + +def _project_experiments_by_id(project: object) -> dict[str, object]: + """Return project experiment objects keyed by datablock id.""" + experiments = getattr(project, 'experiments', None) + values = getattr(experiments, 'values', None) + if not callable(values): + return {} + return {str(getattr(experiment, 'name', '')): experiment for experiment in values()} + + +def _experiment_contexts(context: dict[str, object]) -> list[dict[str, object]]: + """Return experiment contexts from a report context.""" + experiments = context.get('experiments') + if not isinstance(experiments, list): + return [] + return [experiment for experiment in experiments if isinstance(experiment, dict)] + + +def _fit_csv_filename(expt_id: str) -> str: + """Return a filesystem-safe fit-data CSV filename.""" + return f'{_safe_asset_stem(expt_id)}.csv' + + +def _safe_asset_stem(identifier: str) -> str: + """Return a filesystem-safe report asset stem.""" + safe_id = ''.join( + char if char.isascii() and (char.isalnum() or char in {'-', '_'}) else '_' + for char in identifier + ).strip('_') + if not safe_id: + safe_id = 'experiment' + return safe_id + + +def _fit_csv_columns( + expt_id: str, + experiment: dict[str, object], + source_experiment: object | None, + fit_data: dict[str, object], +) -> list[tuple[str, list[object]]]: + """Return ordered CSV columns for one fit-data payload.""" + x_data = fit_data['x'] + series = fit_data['series'] + meas = series['meas'] + calc = series['calc'] + bkg = series.get('bkg') + category_values = _category_values( + source_experiment, + experiment, + code='pd_data', + ) + x_field = _fit_x_field(category_values, fit_data) + row_count = len(list(x_data['values'])) + + columns = [ + ( + _FIT_X_FIELD_TAGS[x_field], + _fit_csv_values( + category_values, + x_field, + list(x_data['values']), + ), + ), + ] + fallback_values = { + 'point_id': [str(index + 1) for index in range(row_count)], + 'd_spacing': _empty_csv_values(row_count), + 'intensity_meas': list(meas['values']), + 'intensity_meas_su': _series_values_or_empty(meas.get('su'), row_count), + 'intensity_calc': list(calc['values']), + 'intensity_bkg': _series_values_or_empty( + None if bkg is None else bkg.get('values'), + row_count, + ), + 'calc_status': _empty_csv_values(row_count), + } + columns.extend( + ( + tag, + _fit_csv_values(category_values, field_name, fallback_values[field_name]), + ) + for field_name, tag in _FIT_CSV_FIELD_TAGS + ) + _validate_fit_csv_columns(expt_id, columns) + return columns + + +def _fit_x_field( + category_values: dict[str, list[object]], + fit_data: dict[str, object], +) -> str: + """Return the pd-data field used as the fit plot x axis.""" + for field_name in _FIT_X_FIELD_TAGS: + if field_name in category_values: + return field_name + x_data = fit_data['x'] + x_name = str(x_data.get('name') or '') + if x_name in _FIT_X_FIELD_TAGS: + return x_name + return 'two_theta' + + +def _fit_csv_plot_columns( + experiment: dict[str, object], + fit_data: dict[str, object], +) -> dict[str, str]: + """Return CSV column tags used by the standalone fit figure.""" + x_field = _fit_x_field(_context_category_values(experiment, 'pd_data'), fit_data) + return { + 'x': _FIT_X_FIELD_TAGS[x_field], + 'meas': '_pd_meas.intensity_total', + 'calc': '_pd_calc.intensity_total', + } + + +def _fit_csv_values( + category_values: dict[str, list[object]], + field_name: str, + fallback: list[object], +) -> list[object]: + """Return category values or fallback values.""" + values = category_values.get(field_name) + if values is None or all(_is_csv_empty(value) for value in values): + return fallback + return values + + +def _series_values_or_empty(values: object, row_count: int) -> list[object]: + """Return series values or an empty CSV column.""" + if values is None: + return _empty_csv_values(row_count) + return list(values) + + +def _empty_csv_values(row_count: int) -> list[object]: + """Return an empty CSV column with ``row_count`` rows.""" + return [''] * row_count + + +def _write_bragg_csvs( + expt_id: str, + experiment: dict[str, object], + source_experiment: object | None, + fit_data: dict[str, object], + out_dir: pathlib.Path, +) -> dict[str, dict[str, str]]: + """Write one Bragg-position CSV per phase.""" + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + values = _category_values(source_experiment, experiment, code='refln') + if values: + return _write_refln_category_csvs(expt_id, values, data_dir) + return _write_bragg_tick_set_csvs(expt_id, fit_data, data_dir) + + +def _write_refln_category_csvs( + expt_id: str, + values: dict[str, list[object]], + data_dir: pathlib.Path, +) -> dict[str, dict[str, str]]: + """Write reflection-category rows split by phase id.""" + phase_values = values.get('phase_id') + if phase_values is None: + return {} + + row_indexes_by_phase: dict[str, list[int]] = {} + for row_index, phase_value in enumerate(phase_values): + if _is_csv_empty(phase_value): + continue + phase_id = str(phase_value) + row_indexes_by_phase.setdefault(phase_id, []).append(row_index) + + csvs: dict[str, dict[str, str]] = {} + x_column = _refln_x_column(values) + for phase_id, row_indexes in row_indexes_by_phase.items(): + csv_path = data_dir / _bragg_csv_filename(expt_id, phase_id) + columns = _refln_csv_columns(values, row_indexes) + _write_csv(csv_path, expt_id, columns) + csvs[phase_id] = { + 'filename': csv_path.name, + 'x_column': x_column, + } + return csvs + + +def _refln_x_column(values: dict[str, list[object]]) -> str: + """Return the reflection CSV x column for Bragg ticks.""" + if 'two_theta' in values: + return '_refln.two_theta' + if 'time_of_flight' in values: + return '_refln.time_of_flight' + return '_refln.two_theta' + + +def _refln_csv_columns( + values: dict[str, list[object]], + row_indexes: list[int], +) -> list[tuple[str, list[object]]]: + """Return ordered reflection CSV columns.""" + return [ + ( + tag, + [values.get(field_name, [])[index] for index in row_indexes], + ) + for field_name, tag in _REFLN_CSV_FIELD_TAGS + if field_name in values + ] + + +def _write_bragg_tick_set_csvs( + expt_id: str, + fit_data: dict[str, object], + data_dir: pathlib.Path, +) -> dict[str, dict[str, str]]: + """Write fallback Bragg CSVs from plot tick-set data.""" + csvs: dict[str, dict[str, str]] = {} + for tick_set in fit_data.get('bragg_tick_sets') or (): + phase_id = str(tick_set.phase_id) + csv_path = data_dir / _bragg_csv_filename(expt_id, phase_id) + columns = _bragg_tick_set_columns(tick_set) + _write_csv(csv_path, expt_id, columns) + csvs[phase_id] = { + 'filename': csv_path.name, + 'x_column': '_refln.two_theta', + } + return csvs + + +def _bragg_tick_set_columns(tick_set: object) -> list[tuple[str, list[object]]]: + """Return reflection CSV columns from one Bragg tick set.""" + row_count = len(tick_set.x) + return [ + ('_refln.id', [str(index + 1) for index in range(row_count)]), + ('_refln.phase_id', [tick_set.phase_id] * row_count), + ('_refln.index_h', list(tick_set.h)), + ('_refln.index_k', list(tick_set.k)), + ('_refln.index_l', list(tick_set.ell)), + ('_refln.f_calc', list(tick_set.f_calc)), + ('_refln.f_squared_calc', list(tick_set.f_squared_calc)), + ('_refln.two_theta', list(tick_set.x)), + ] + + +def _bragg_csv_filename(expt_id: str, phase_id: str) -> str: + """Return the Bragg-position CSV filename for one phase.""" + return f'{_safe_asset_stem(expt_id)}_{_safe_asset_stem(phase_id)}.csv' + + +def _bragg_tick_sources( + fit_data: dict[str, object], + bragg_csvs: dict[str, dict[str, str]], +) -> list[dict[str, str]]: + """Return template context for Bragg-position CSV sources.""" + sources = [] + for tick_set in fit_data.get('bragg_tick_sets') or (): + phase_id = str(tick_set.phase_id) + bragg_csv = bragg_csvs.get(phase_id) + if bragg_csv is None: + continue + sources.append({ + 'phase_id': phase_id, + 'csv_filename': bragg_csv['filename'], + 'x_column': bragg_csv['x_column'], + }) + return sources + + +def _category_values( + source_experiment: object | None, + experiment: dict[str, object], + *, + code: str, +) -> dict[str, list[object]]: + """Return full category values from the project or context.""" + if source_experiment is not None: + values = _source_category_values(source_experiment, code) + if values: + return values + return _context_category_values(experiment, code) + + +def _source_category_values( + source_experiment: object, + code: str, +) -> dict[str, list[object]]: + """Return category values from a live experiment object.""" + category = _source_category(source_experiment, code) + if category is None: + return {} + category_values = getattr(category, 'values', None) + if not callable(category_values): + return {} + items = list(category_values()) + if not items: + return {} + parameter_rows = [_category_parameters(category, item) for item in items] + names = [parameter.name for parameter in parameter_rows[0]] + values = {name: [] for name in names} + for parameters in parameter_rows: + for name, parameter in zip(names, parameters, strict=True): + values[name].append(_raw_value(parameter)) + return values + + +def _source_category(source_experiment: object, code: str) -> object | None: + """Return a live experiment category matching ``code``.""" + for category in getattr(source_experiment, 'categories', ()): + if _source_category_code(category) == code: + return category + return None + + +def _source_category_code(category: object) -> str | None: + """Return a live category code.""" + identity = getattr(category, '_identity', None) + category_code = getattr(identity, 'category_code', None) + if category_code is not None: + return category_code + item_type = getattr(category, '_item_type', None) + return getattr(item_type, '_category_code', None) + + +def _category_parameters(category: object, item: object) -> list[object]: + """Return loop parameters for one live category row.""" + loop_parameters = getattr(category, '_cif_loop_parameters', None) + if callable(loop_parameters): + return list(loop_parameters(item)) + return list(getattr(item, 'parameters', ())) + + +def _raw_value(value: object) -> object: + """Return a descriptor's raw value for CSV output.""" + return getattr(value, 'value', value) + + +def _context_category_values( + experiment: dict[str, object], + code: str, +) -> dict[str, list[object]]: + """Return category values from a prepared report context.""" + for category in experiment.get('categories') or (): + if not isinstance(category, dict) or category.get('code') != code: + continue + return _context_loop_values(category) + return {} + + +def _context_loop_values(category: dict[str, object]) -> dict[str, list[object]]: + """Return loop values from a prepared category context.""" + columns = category.get('columns') or [] + rows = category.get('rows') or [] + names = [str(column.get('name')) for column in columns if isinstance(column, dict)] + values = {name: [] for name in names} + for row in rows: + if not isinstance(row, dict): + continue + cells = row.get('cells') or [] + for name, cell in zip(names, cells, strict=True): + values[name].append(cell.get('value') if isinstance(cell, dict) else '') + return values + + +def _is_csv_empty(value: object) -> bool: + """Return whether a value should be treated as empty in CSV data.""" + return value is None or (isinstance(value, str) and not value) + + +def _write_csv( + path: pathlib.Path, + expt_id: str, + columns: list[tuple[str, list[object]]], +) -> None: + """Write a CSV file after validating column lengths.""" + _validate_fit_csv_columns(expt_id, columns) + with path.open('w', newline='', encoding='utf-8') as handle: + writer = csv.writer(handle) + writer.writerow([name for name, _values in columns]) + writer.writerows(zip(*(values for _name, values in columns), strict=True)) + + +def _validate_fit_csv_columns( + expt_id: str, + columns: list[tuple[str, list[object]]], +) -> None: + """Raise if fit-data CSV columns have inconsistent lengths.""" + reference_name = columns[0][0] + expected = len(columns[0][1]) + for name, values in columns[1:]: + if len(values) == expected: + continue + msg = ( + f"Cannot write report CSV for experiment '{expt_id}': " + f"column '{reference_name}' has length {expected}, but column " + f"'{name}' has length {len(values)}." + ) + raise ValueError(msg) + + +def _tex_number(value: object, digits: int = 6) -> str: + """Format a number for TeX output.""" + if isinstance(value, bool): + return _tex_escape(value) + if isinstance(value, (float, int)): + return f'{value:.{digits}g}' + return _tex_escape(value) + + +def _tex_markup(value: object) -> str: + """Escape plain text while preserving explicit TeX snippets.""" + if value is None: + return '' + text = str(value) + if '\\' in text or '$' in text: + return text + return _tex_escape(text) + + +def _tex_unit(value: object) -> str: + """Return TeX-safe unit text for table labels.""" + if value is None: + return '' + text = str(value) + if not text: + return '' + if '\\' in text or '$' in text: + return f'${_tex_unit_math(text.replace("$", ""))}$' + return _tex_escape(text) + + +def _tex_unit_math(value: str) -> str: + """Return unit TeX normalized for math-mode rendering.""" + placeholder = '__EASYDIFFRACTION_ANGSTROM__' + text = _tex_degree_unit_math(value) + text = text.replace(r'\mathrm{\AA}', placeholder) + text = text.replace(r'\AA', r'\mathring{\mathrm{A}}') + return text.replace(placeholder, r'\mathring{\mathrm{A}}') + + +def _tex_degree_unit_math(value: str) -> str: + """Return TeX unit markup with degree symbols named as deg.""" + text = value + markers = (r'^\circ{}^2', r'^\circ{}^{2}', r'^\circ^2', r'^\circ^{2}') + for marker in markers: + text = text.replace(marker, r'\mathrm{deg}^2') + return text.replace(r'^\circ{}', r'\mathrm{deg}').replace( + r'^\circ', + r'\mathrm{deg}', + ) + + +def _tex_axis_label(value: object) -> str: + """Return a TeX-safe axis label from Plotly display text.""" + if value is None: + return '' + text = str(value) + text = text.replace('degree', 'deg') + text = text.replace('⁻¹', '$^{-1}$') + text = text.replace('²', '$^2$') + text = text.replace('θ', r'$\theta$') + text = text.replace('λ', r'$\lambda$') + text = text.replace('μ', r'$\mu$') + text = text.replace('Å', r'\AA{}') + return _tex_markup(text) + + +def _tex_escape(value: object) -> str: + """Escape user-provided text for TeX output.""" + if value is None: + return '' + if isinstance(value, (list, tuple, set)): + text = ', '.join(str(item) for item in value if item is not None) + else: + text = str(value) + text = text.replace('\r\n', '\n').replace('\r', '\n') + escaped = ''.join(_TEX_SPECIAL_CHARS.get(char, char) for char in text) + return escaped.replace('\n\n', r'\par ').replace('\n', ' ') diff --git a/src/easydiffraction/utils/matplotlib_config.py b/src/easydiffraction/utils/matplotlib_config.py new file mode 100644 index 000000000..33230d82b --- /dev/null +++ b/src/easydiffraction/utils/matplotlib_config.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Configure Matplotlib cache paths for restricted environments.""" + +from __future__ import annotations + +import os +import pathlib +import tempfile + + +def _path_is_writable(path: pathlib.Path) -> bool: + """Return whether runtime caches can use a directory.""" + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / '.easydiffraction-write-test' + probe.write_text('', encoding='utf-8') + probe.unlink() + except OSError: + return False + return True + + +def ensure_matplotlib_config_dir() -> None: + """Set a stable Matplotlib cache dir.""" + if os.environ.get('MPLCONFIGDIR'): + return + default_dir = pathlib.Path.home() / '.matplotlib' + if _path_is_writable(default_dir): + return + fallback_dir = pathlib.Path(tempfile.gettempdir()) / 'easydiffraction-matplotlib' + if _path_is_writable(fallback_dir): + os.environ['MPLCONFIGDIR'] = str(fallback_dir) + + +ensure_matplotlib_config_dir() diff --git a/tests/integration/fitting/test_summary_report.py b/tests/integration/fitting/test_summary_report.py index 7b644e678..3b5762792 100644 --- a/tests/integration/fitting/test_summary_report.py +++ b/tests/integration/fitting/test_summary_report.py @@ -4,34 +4,14 @@ """Integration tests for report generation and CIF export.""" -def test_show_report(lbco_fitted_project): - project = lbco_fitted_project - project.report.show_report() - - -def test_show_project_info(lbco_fitted_project): - project = lbco_fitted_project - project.report.show_project_info() - - -def test_show_crystallographic_data(lbco_fitted_project): - project = lbco_fitted_project - project.report.show_crystallographic_data() - - -def test_show_experimental_data(lbco_fitted_project): - project = lbco_fitted_project - project.report.show_experimental_data() - - -def test_show_fitting_details(lbco_fitted_project): - project = lbco_fitted_project - project.report.show_fitting_details() - - def test_report_save(lbco_fitted_project, tmp_path): project = lbco_fitted_project project.save_as(str(tmp_path / 'proj')) - report_path = project.report.save() + project.report.cif = True + project.report.html = False + report_paths = project.report.save() + report_path = report_paths[0] + + assert len(report_paths) == 1 assert report_path.is_file() assert report_path.read_text(encoding='utf-8').startswith('data_global\n') diff --git a/tests/unit/easydiffraction/analysis/categories/software/test_base.py b/tests/unit/easydiffraction/analysis/categories/software/test_base.py new file mode 100644 index 000000000..202b5d938 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/software/test_base.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_software_role_serializes_name_version_and_url(): + from easydiffraction.analysis.categories.software.base import SoftwareRole + + role = SoftwareRole(role_name='calculator', description='Calculator') + role.name = 'cryspy' + role.version = '1.0' + role.url = 'https://example.invalid/cryspy' + + assert [parameter.name for parameter in role.parameters] == [ + 'calculator_name', + 'calculator_version', + 'calculator_url', + ] + assert '_software.calculator_name cryspy' in role.as_cif + assert '_software.calculator_version 1.0' in role.as_cif + assert '_software.calculator_url https://example.invalid/cryspy' in role.as_cif diff --git a/tests/unit/easydiffraction/analysis/categories/software/test_default.py b/tests/unit/easydiffraction/analysis/categories/software/test_default.py new file mode 100644 index 000000000..ebef86b30 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/software/test_default.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_software_category_exposes_roles_and_timestamp(): + from easydiffraction.analysis.categories.software.base import SoftwareRole + from easydiffraction.analysis.categories.software.default import Software + + software = Software() + software.framework.name = 'EasyDiffraction' + software.calculator.name = 'cryspy' + software.minimizer.name = 'lmfit' + software.timestamp = '2026-05-29T12:00:00+00:00' + + assert isinstance(software.framework, SoftwareRole) + assert isinstance(software.calculator, SoftwareRole) + assert isinstance(software.minimizer, SoftwareRole) + assert len(software.parameters) == 10 + assert '_software.framework_name EasyDiffraction' in software.as_cif + assert '_software.calculator_name cryspy' in software.as_cif + assert '_software.minimizer_name lmfit' in software.as_cif + assert '_software.timestamp 2026-05-29T12:00:00+00:00' in software.as_cif diff --git a/tests/unit/easydiffraction/analysis/categories/software/test_factory.py b/tests/unit/easydiffraction/analysis/categories/software/test_factory.py new file mode 100644 index 000000000..2a4086af1 --- /dev/null +++ b/tests/unit/easydiffraction/analysis/categories/software/test_factory.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import pytest + + +def test_software_factory_default_and_create(): + from easydiffraction.analysis.categories.software.default import Software + from easydiffraction.analysis.categories.software.factory import SoftwareFactory + + assert SoftwareFactory.default_tag() == 'default' + assert 'default' in SoftwareFactory.supported_tags() + + software = SoftwareFactory.create('default') + + assert isinstance(software, Software) + + +def test_software_factory_rejects_unknown_tag(): + from easydiffraction.analysis.categories.software.factory import SoftwareFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + SoftwareFactory.create('missing') diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 6786d839f..5cda12a5d 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -784,6 +784,7 @@ def test_run_sequential_sets_mode_and_saves_project(monkeypatch, tmp_path): project = SimpleNamespace( info=SimpleNamespace(path=tmp_path), + experiments=SimpleNamespace(values=list), save_calls=0, _varname='proj', ) diff --git a/tests/unit/easydiffraction/core/test_display_handler.py b/tests/unit/easydiffraction/core/test_display_handler.py new file mode 100644 index 000000000..38dbaa804 --- /dev/null +++ b/tests/unit/easydiffraction/core/test_display_handler.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_display_handler_is_frozen_value_object(): + import dataclasses + + import pytest + + from easydiffraction.core.display_handler import DisplayHandler + + handler = DisplayHandler( + display_name='2theta', + display_units='deg', + latex_name=r'$2\theta$', + latex_units=r'$^\circ$', + ) + + assert dataclasses.asdict(handler) == { + 'display_name': '2theta', + 'display_units': 'deg', + 'latex_name': r'$2\theta$', + 'latex_units': r'$^\circ$', + } + with pytest.raises(dataclasses.FrozenInstanceError): + handler.display_name = 'other' diff --git a/tests/unit/easydiffraction/core/test_errors.py b/tests/unit/easydiffraction/core/test_errors.py new file mode 100644 index 000000000..6b9843cb7 --- /dev/null +++ b/tests/unit/easydiffraction/core/test_errors.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_writer_error_is_easy_diffraction_error(): + from easydiffraction.core.errors import EasyDiffractionError + from easydiffraction.core.errors import EasyDiffractionWriterError + + error = EasyDiffractionWriterError('failed') + + assert isinstance(error, EasyDiffractionError) + assert str(error) == 'failed' diff --git a/tests/unit/easydiffraction/core/test_parameters.py b/tests/unit/easydiffraction/core/test_parameters.py index f81e35e87..a4a4979a3 100644 --- a/tests/unit/easydiffraction/core/test_parameters.py +++ b/tests/unit/easydiffraction/core/test_parameters.py @@ -35,13 +35,13 @@ def test_numeric_descriptor_str_includes_units(): d = NumericDescriptor( name='w', value_spec=AttributeSpec(default=1.23), - units='deg', + units='degrees', cif_handler=CifHandler(names=['_x.w']), ) s = str(d) assert s.startswith('<') assert s.endswith('>') - assert 'deg' in s + assert 'degrees' in s assert 'w' in s @@ -53,7 +53,7 @@ def test_parameter_string_repr_and_as_cif_and_flags(): p = Parameter( name='a', value_spec=AttributeSpec(default=0.0), - units='A', + units='angstroms', cif_handler=CifHandler(names=['_param.a']), ) p.value = 2.5 @@ -63,7 +63,7 @@ def test_parameter_string_repr_and_as_cif_and_flags(): s = str(p) assert '± 0.1' in s - assert 'A' in s + assert 'angstroms' in s assert '(free=True)' in s # CIF line: free param with uncertainty uses 2-sig-digit esd brackets diff --git a/tests/unit/easydiffraction/core/test_units_vocabulary.py b/tests/unit/easydiffraction/core/test_units_vocabulary.py new file mode 100644 index 000000000..141b1c8bf --- /dev/null +++ b/tests/unit/easydiffraction/core/test_units_vocabulary.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import pytest + + +@pytest.mark.parametrize( + 'code', + [ + 'angstrom_squared', + 'angstroms', + 'arcminutes', + 'degrees', + 'degrees_squared', + 'kelvins', + 'kilopascals', + 'microsecond_angstroms', + 'microseconds', + 'microseconds_per_angstrom', + 'microseconds_per_angstrom_squared', + 'microseconds_squared', + 'microseconds_squared_per_angstrom_squared', + 'micrometres', + 'none', + 'reciprocal_angstrom_squared', + 'reciprocal_angstroms', + 'teslas', + 'volts_per_metre', + ], +) +def test_units_vocabulary_accepts_known_codes(code): + from easydiffraction.core.units_vocabulary import normalize_units_code + from easydiffraction.core.units_vocabulary import validate_units_code + + validate_units_code(code) + + assert normalize_units_code(code) == code + + +def test_units_vocabulary_normalizes_empty_string_to_none(): + from easydiffraction.core.units_vocabulary import normalize_units_code + + assert normalize_units_code('') == 'none' + + +def test_units_vocabulary_rejects_unknown_code(): + from easydiffraction.core.units_vocabulary import validate_units_code + + with pytest.raises(ValueError, match="Unknown units code: 'deg'"): + validate_units_code('deg') diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py index ffae191b7..bb059d69b 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py @@ -29,14 +29,22 @@ def test_refln_data_point_defaults(): assert pt.intensity_meas.value == 0.0 assert pt.intensity_meas_su.value == 0.0 assert pt.intensity_calc.value == 0.0 - assert pt.wavelength.value == 0.0 + assert not hasattr(type(pt), 'wavelength') assert pt._identity.category_code == 'refln' +def test_tof_refln_data_point_has_wavelength(): + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import TofRefln + + pt = TofRefln() + assert pt.wavelength.value == 0.0 + assert pt.intensity_calc.value == 0.0 + + def test_refln_data_collection_create_and_properties(): - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import TofReflnData - coll = ReflnData() + coll = TofReflnData() h = np.array([1.0, 2.0, 0.0]) k = np.array([0.0, 1.0, 0.0]) @@ -68,10 +76,18 @@ def test_refln_data_collection_create_and_properties(): np.testing.assert_array_almost_equal(coll.intensity_calc, calc) +def test_cwl_refln_data_has_no_wavelength(): + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import CwlReflnData + + coll = CwlReflnData() + assert not hasattr(type(coll), 'wavelength') + assert not hasattr(type(coll), '_set_wavelength') + + def test_refln_data_d_spacing_and_stol(): - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import CwlReflnData - coll = ReflnData() + coll = CwlReflnData() h = np.array([1.0, 2.0]) k = np.array([0.0, 0.0]) l = np.array([0.0, 0.0]) @@ -87,9 +103,9 @@ def test_refln_data_d_spacing_and_stol(): def test_refln_items_resolve_experiment_datablock_name(): - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import CwlReflnData - coll = ReflnData() + coll = CwlReflnData() coll._parent = _experiment_stub('sc-exp') coll._create_items_set_hkl_and_id( @@ -104,7 +120,10 @@ def test_refln_items_resolve_experiment_datablock_name(): def test_refln_data_type_info(): - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import CwlReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import TofReflnData - assert ReflnData.type_info.tag == 'bragg-sc' - assert ReflnData.type_info.description == 'Bragg single-crystal reflection data' + assert CwlReflnData.type_info.tag == 'bragg-sc-cwl' + assert CwlReflnData.type_info.description == 'Bragg CWL single-crystal reflection data' + assert TofReflnData.type_info.tag == 'bragg-sc-tof' + assert TofReflnData.type_info.description == 'Bragg TOF single-crystal reflection data' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py index 76ac4b49a..61ffa9ec3 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py @@ -7,8 +7,11 @@ def test_refln_factory_default_and_errors(): from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory - obj = ReflnFactory.create('bragg-sc') - assert obj.__class__.__name__ == 'ReflnData' + obj = ReflnFactory.create('bragg-sc-cwl') + assert obj.__class__.__name__ == 'CwlReflnData' + + obj_tof = ReflnFactory.create('bragg-sc-tof') + assert obj_tof.__class__.__name__ == 'TofReflnData' obj2 = ReflnFactory.create('bragg-pd-refln') assert obj2.__class__.__name__ == 'PowderCwlReflnData' @@ -34,14 +37,14 @@ def test_refln_factory_default_tag_resolution(): scattering_type=ScatteringTypeEnum.BRAGG, beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH, ) - assert tag == 'bragg-sc' + assert tag == 'bragg-sc-cwl' tag = ReflnFactory.default_tag( sample_form=SampleFormEnum.SINGLE_CRYSTAL, scattering_type=ScatteringTypeEnum.BRAGG, beam_mode=BeamModeEnum.TIME_OF_FLIGHT, ) - assert tag == 'bragg-sc' + assert tag == 'bragg-sc-tof' tag = ReflnFactory.default_tag( sample_form=SampleFormEnum.POWDER, @@ -62,6 +65,7 @@ def test_refln_factory_supported_tags(): from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory tags = ReflnFactory.supported_tags() - assert 'bragg-sc' in tags + assert 'bragg-sc-cwl' in tags + assert 'bragg-sc-tof' in tags assert 'bragg-pd-refln' in tags assert 'bragg-pd-tof-refln' in tags diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index 54e533ea0..809cbc772 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -121,7 +121,7 @@ def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row( f_calc=np.array([10.0]), ), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder plot', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 097d4321f..35fd5f2b8 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -416,6 +416,7 @@ def fake_show_figure(self, fig): y_meas=np.array([10.0, 12.0, 11.0]), y_calc=np.array([9.0, 11.0, 10.5]), y_resid=np.array([1.0, 1.0, 0.5]), + y_meas_su=np.array([0.2, 0.3, 0.4]), bragg_tick_sets=( BraggTickSet( phase_id='phase-a', @@ -436,7 +437,7 @@ def fake_show_figure(self, fig): f_calc=np.array([9.0]), ), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -477,6 +478,7 @@ def fake_show_figure(self, fig): assert calc_trace.hovertemplate == expected_hovertemplate assert residual_trace.hovertemplate == expected_hovertemplate assert meas_trace.line.width == pp.MEASURED_LINE_WIDTH + assert list(meas_trace.error_y.array) == pytest.approx([0.2, 0.3, 0.4]) assert calc_trace.line.width == pp.CALCULATED_LINE_WIDTH assert residual_trace.line.width == pp.RESIDUAL_LINE_WIDTH assert list(meas_trace.customdata[0]) == pytest.approx([10.0, 9.0, 1.0]) @@ -495,7 +497,7 @@ def fake_show_figure(self, fig): assert fig.layout.yaxis2.title.text is None assert fig.layout.yaxis3.title.text is None assert fig.layout.yaxis3.zeroline is False - assert fig.layout.xaxis3.title.text == '2θ (degree)' + assert fig.layout.xaxis3.title.text == '2θ (deg)' assert 'Miller indices: (1 0 1)' in bragg_traces[0].text[0] assert 'phase-a' in bragg_traces[0].text[0] @@ -529,7 +531,7 @@ def fake_show_figure(self, fig): f_calc=np.array([10.0]), ), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -578,7 +580,7 @@ def test_get_main_intensity_range_uses_unit_padding_for_flat_series(): y_calc=np.array([5.0]), y_resid=None, bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -610,7 +612,7 @@ def test_bragg_row_height_pixels_scale_linearly_with_phase_count(): f_calc=np.array([10.0]), ), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -679,7 +681,7 @@ def plot_spec(phase_count: int) -> PowderMeasVsCalcSpec: y_calc=np.array([9.0, 11.0, 10.5]), y_resid=np.array([1.0, 1.0, 0.5]), bragg_tick_sets=bragg_tick_sets, - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -740,7 +742,7 @@ def fake_show_figure(self, fig): f_calc=np.array([10.0]), ), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.10, @@ -771,7 +773,7 @@ def fake_show_figure(self, fig): y_calc=np.array([9.0, 11.0, 10.5]), y_resid=np.array([1.0, 1.0, 0.5]), bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, @@ -784,7 +786,7 @@ def fake_show_figure(self, fig): assert fig.layout.xaxis.matches == 'x' assert fig.layout.xaxis2.matches == 'x' assert fig.layout.yaxis2.title.text is None - assert fig.layout.xaxis2.title.text == '2θ (degree)' + assert fig.layout.xaxis2.title.text == '2θ (deg)' assert fig.layout.title.font.size == pp.TITLE_FONT_SIZE assert fig.layout.yaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE assert fig.layout.xaxis2.title.font.size == pp.AXIS_TITLE_FONT_SIZE @@ -815,7 +817,7 @@ def fake_show_figure(self, fig): y_calc=np.array([9.0, 11.0, 10.5]), y_resid=np.array([1.0, 1.0, 0.5]), bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, @@ -862,7 +864,7 @@ def fake_show_figure(self, fig): y_calc=np.array([180.0, 3400.0, 210.0]), y_resid=np.array([20.0, 200.0, 10.0]), bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, @@ -917,7 +919,7 @@ def fake_show_figure(self, fig): y_calc=np.array([180.0, 3400.0, 210.0]), y_resid=np.array([20.0, 1200.0, 10.0]), bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, @@ -956,7 +958,7 @@ def fake_show_figure(self, fig): y_calc=np.array([], dtype=float), y_resid=np.array([], dtype=float), bragg_tick_sets=(), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], title='Powder', residual_height_fraction=0.25, bragg_peaks_height_fraction=0.15, diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index d5b6ae76c..de9b86845 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -843,7 +843,7 @@ def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(mon best_sample_prediction=np.array([9.0, 10.0, 11.0]), ), y_meas=np.array([9.5, 10.5, 11.5]), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], show_band=True, show_draws=False, ) @@ -1046,7 +1046,7 @@ def test_plot_posterior_predictive_summary_routes_ascii_to_measured_and_map(monk draws=np.array([[8.5, 9.5, 10.5]]), ), y_meas=np.array([9.5, 10.5, 11.5]), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], show_band=True, show_draws=True, excluded_ranges=((1.2, 1.4),), @@ -1644,6 +1644,7 @@ class Pattern: two_theta = np.array([0.0, 1.0, 2.0, 3.0]) d_spacing = two_theta intensity_meas = np.array([10.0, 20.0, 30.0, 40.0]) + intensity_meas_su = np.array([0.1, 0.2, 0.3, 0.4]) intensity_bkg = np.array([1.0, 2.0, 3.0, 4.0]) intensity_calc = np.array([9.0, 18.0, 27.0, 39.0]) @@ -1679,6 +1680,7 @@ class Experiment: call = captured['powder_meas_vs_calc'] assert np.allclose(call.x, np.array([1.0, 2.0])) assert np.allclose(call.y_meas, np.array([20.0, 30.0])) + assert np.allclose(call.y_meas_su, np.array([0.2, 0.3])) assert np.allclose(call.y_bkg, np.array([2.0, 3.0])) assert np.allclose(call.y_calc, np.array([18.0, 27.0])) assert np.allclose(call.y_resid, np.array([2.0, 3.0])) diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py index a18cdaa79..2b4731bbd 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py @@ -113,6 +113,20 @@ def test_extinction_transformer_emits_becker_coppens_type_1(): assert items['_easydiffraction_extinction.mosaicity'] == 0.12 +def test_extinction_transformer_handles_real_switchable_type_property(): + from easydiffraction.datablocks.experiment.categories.extinction.becker_coppens import ( + BeckerCoppensExtinction, + ) + from easydiffraction.io.cif.iucr_transformers import ExtinctionTransformer + + extinction = BeckerCoppensExtinction() + + items = _items_by_tag(ExtinctionTransformer().items(SimpleNamespace(extinction=extinction))) + + assert items['_easydiffraction_extinction.type'] == 'becker-coppens' + assert items['_easydiffraction_extinction.model'] == 'gauss' + + def test_extinction_transformer_emits_becker_coppens_type_2(): from easydiffraction.io.cif.iucr_transformers import ExtinctionTransformer diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py index 260543c37..12217863d 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py @@ -317,6 +317,8 @@ def test_write_iucr_cif_emits_powder_cwl_blocks(tmp_path): assert 'data_powder_pwd_1' in text assert '_pd_meas.2theta_scan' in text assert '_pd_meas.time_of_flight' not in text + assert '_pd_refln.phase_id' in text + assert '_refln.phase_calc' not in text assert '_pd_proc.info_excluded_regions' in text assert '_easydiffraction_background.type' in text @@ -342,3 +344,118 @@ def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path): assert '_pd_calib_d_to_tof.power' in text assert 'recip' in text assert '-1' in text + + +def test_iucr_loop_rows_are_not_padded_to_tag_width(): + from easydiffraction.io.cif.iucr_writer import _write_loop + + lines = [] + + _write_loop( + lines, + ('_atom_site_aniso.label', '_atom_site_aniso.U_11'), + (('Tb', 0.00658189), ('O1', 0.0)), + ) + + assert lines == [ + 'loop_', + '_atom_site_aniso.label', + '_atom_site_aniso.U_11', + ' Tb 0.00658189', + ' O1 0.', + ] + + +def test_iucr_atom_site_rows_preserve_parameter_uncertainties(): + from easydiffraction.datablocks.structure.categories.atom_sites.default import ( + AtomSite, + ) + from easydiffraction.io.cif.iucr_writer import _atom_site_row + from easydiffraction.io.cif.iucr_writer import _atom_site_tags + from easydiffraction.io.cif.iucr_writer import _write_loop + from easydiffraction.io.cif.serialize import format_param_value + + atom_site = AtomSite() + atom_site.label = 'Si1' + atom_site.type_symbol = 'Si' + atom_site.fract_x = 11.98509310 + atom_site.fract_x.free = True + atom_site.fract_x.uncertainty = 0.03069505 + lines = [] + + _write_loop(lines, _atom_site_tags('B'), (_atom_site_row(atom_site),)) + + assert format_param_value(atom_site.fract_x) in lines[-1].split() + + +def test_iucr_atom_site_aniso_rows_preserve_parameter_uncertainties(): + from easydiffraction.datablocks.structure.categories.atom_site_aniso.default import ( + AtomSiteAniso, + ) + from easydiffraction.io.cif.iucr_writer import _atom_site_aniso_row + from easydiffraction.io.cif.iucr_writer import _atom_site_aniso_tags + from easydiffraction.io.cif.iucr_writer import _write_loop + from easydiffraction.io.cif.serialize import format_param_value + + aniso_site = AtomSiteAniso() + aniso_site.label = 'Si1' + aniso_site.adp_11 = 0.00658189 + aniso_site.adp_11.free = True + aniso_site.adp_11.uncertainty = 0.00014 + lines = [] + + _write_loop(lines, _atom_site_aniso_tags('B'), (_atom_site_aniso_row(aniso_site),)) + + assert format_param_value(aniso_site.adp_11) in lines[-1].split() + + +def test_iucr_extension_items_preserve_parameter_uncertainties(): + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.iucr_writer import _iucr_items + from easydiffraction.io.cif.iucr_writer import _write_item + from easydiffraction.io.cif.serialize import format_param_value + + scale = Parameter( + name='scale', + value_spec=AttributeSpec(default=1.0), + cif_handler=CifHandler( + names=['_sc_crystal_block.scale'], + iucr_name='_easydiffraction_sc_crystal_block.scale', + ), + ) + scale.value = 2.87438284 + scale.free = True + scale.uncertainty = 0.0274 + owner = SimpleNamespace(scale=scale) + tag, value = _iucr_items(owner, ('scale',))[0] + lines = [] + + _write_item(lines, tag, value) + + assert format_param_value(scale) in lines[0] + + +def test_iucr_extinction_extensions_preserve_parameter_uncertainties(): + from easydiffraction.datablocks.experiment.categories.extinction.becker_coppens import ( + BeckerCoppensExtinction, + ) + from easydiffraction.io.cif.iucr_writer import _extinction_items + from easydiffraction.io.cif.iucr_writer import _write_item + from easydiffraction.io.cif.serialize import format_param_value + + extinction = BeckerCoppensExtinction() + extinction.radius = 24.83171997 + extinction.radius.free = True + extinction.radius.uncertainty = 0.4931 + experiment = SimpleNamespace(extinction=extinction) + items = {item.tag: item.value for item in _extinction_items(experiment, extension=True)} + lines = [] + + _write_item( + lines, + '_easydiffraction_extinction.radius', + items['_easydiffraction_extinction.radius'], + ) + + assert format_param_value(extinction.radius) in lines[0] diff --git a/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py b/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py index 465fd76ae..808b1a948 100644 --- a/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py +++ b/tests/unit/easydiffraction/io/cif/test_serialize_category_owner_baseline.py @@ -5,6 +5,7 @@ from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory from easydiffraction.datablocks.structure.item.base import Structure +from easydiffraction.io.cif.serialize import analysis_from_cif from easydiffraction.project.project import Project @@ -34,11 +35,51 @@ def test_real_analysis_as_cif_is_singleton_section_without_data_header() -> None assert analysis_cif.startswith('_fitting_mode.type single') assert not analysis_cif.startswith('data_') assert '_minimizer.type' in analysis_cif + assert '_software.framework_name' not in analysis_cif assert '_joint_fit.experiment_id' not in analysis_cif assert '_sequential_fit.data_dir' not in analysis_cif assert '_sequential_fit_extract.id' not in analysis_cif +def test_real_analysis_as_cif_includes_stamped_software() -> None: + project = Project(name='proj') + analysis = project.analysis + analysis.software.framework.name = 'EasyDiffraction' + analysis.software.framework.version = '0.17.0' + analysis.software.framework.url = 'https://github.com/easyscience/diffraction-lib' + analysis.software.calculator.name = 'cryspy' + analysis.software.calculator.version = '0.11.0' + analysis.software.minimizer.name = 'lmfit' + analysis.software.minimizer.version = '1.3.4' + analysis.software.timestamp = '2026-05-29T12:00:00+00:00' + + analysis_cif = analysis.as_cif + + assert '_software.framework_name EasyDiffraction' in analysis_cif + assert '_software.framework_version 0.17.0' in analysis_cif + assert '_software.calculator_name cryspy' in analysis_cif + assert '_software.calculator_version 0.11.0' in analysis_cif + assert '_software.minimizer_name lmfit' in analysis_cif + assert '_software.minimizer_version 1.3.4' in analysis_cif + assert '_software.timestamp 2026-05-29T12:00:00+00:00' in analysis_cif + + +def test_real_analysis_from_cif_restores_stamped_software() -> None: + source = Project(name='proj') + source.analysis.software.framework.name = 'EasyDiffraction' + source.analysis.software.framework.version = '0.17.0' + source.analysis.software.calculator.name = 'cryspy' + source.analysis.software.minimizer.name = 'lmfit' + + target = Project(name='restored') + analysis_from_cif(target.analysis, source.analysis.as_cif) + + assert target.analysis.software.framework.name.value == 'EasyDiffraction' + assert target.analysis.software.framework.version.value == '0.17.0' + assert target.analysis.software.calculator.name.value == 'cryspy' + assert target.analysis.software.minimizer.name.value == 'lmfit' + + def test_real_analysis_as_cif_includes_aliases_and_constraints_when_present() -> None: project = Project(name='proj') project.structures.create(name='phase_1') diff --git a/tests/unit/easydiffraction/project/categories/publication/test_default.py b/tests/unit/easydiffraction/project/categories/publication/test_default.py new file mode 100644 index 000000000..4fe0b4462 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/publication/test_default.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_publication_instantiates_and_serializes_to_cif(): + from easydiffraction.project.categories.publication.default import Publication + + publication = Publication() + publication.body.title = 'Refinement report' + publication.authors.add(name='Ada Lovelace') + + cif_text = publication.as_cif + + assert not cif_text.startswith('data_') + assert '_publ_body.title' in cif_text + assert 'Refinement report' in cif_text + assert '_publ_author.name' in cif_text + assert 'Ada Lovelace' in cif_text diff --git a/tests/unit/easydiffraction/project/categories/publication/test_factory.py b/tests/unit/easydiffraction/project/categories/publication/test_factory.py new file mode 100644 index 000000000..872399697 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/publication/test_factory.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import pytest + + +def test_publication_factory_default_and_create(): + from easydiffraction.project.categories.publication.default import Publication + from easydiffraction.project.categories.publication.factory import PublicationFactory + + assert PublicationFactory.default_tag() == 'default' + assert 'default' in PublicationFactory.supported_tags() + + publication = PublicationFactory.create('default') + + assert isinstance(publication, Publication) + + +def test_publication_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.publication.factory import PublicationFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + PublicationFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/report/test_default.py b/tests/unit/easydiffraction/project/categories/report/test_default.py new file mode 100644 index 000000000..2b01c89cf --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/report/test_default.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for project report category behavior.""" + +from __future__ import annotations + +import pytest + + +def test_report_has_no_formats_property(monkeypatch): + from easydiffraction.project.categories.report.default import Report + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + report = Report() + + with pytest.raises(AttributeError, match="Unknown attribute 'formats'"): + report.formats + + +def test_report_save_without_outputs_points_to_boolean_flags(): + from easydiffraction.project.categories.report.default import Report + + report = Report() + report.html = False + + with pytest.raises(ValueError, match='no formats enabled') as exc_info: + report.save() + + message = str(exc_info.value) + assert 'project.report.{cif,html,tex,pdf}' in message + assert 'project.report.formats' not in message + + +def test_report_html_enabled_by_default(): + from easydiffraction.project.categories.report.default import Report + + report = Report() + assert report.html.value is True + assert report.cif.value is False + assert report.tex.value is False + assert report.pdf.value is False + + +def test_save_configured_reuses_tex_bundle_for_pdf(tmp_path, monkeypatch): + from easydiffraction.project.categories.report.default import Report + from easydiffraction.report import pdf_compiler + + tex_path = tmp_path / 'reports' / 'tex' / 'demo.tex' + pdf_path = tmp_path / 'reports' / 'demo.pdf' + calls = [] + report = Report() + report.html = False + report.tex = True + report.pdf = True + + def fake_save_tex(self): + del self + calls.append('tex') + return tex_path + + def fake_save_pdf(self): + del self + msg = 'save_pdf should not regenerate TeX when tex was already saved.' + raise AssertionError(msg) + + def fake_compile_pdf_report(path): + calls.append(('pdf', path)) + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_text('%PDF', encoding='utf-8') + return pdf_path + + monkeypatch.setattr(Report, 'save_tex', fake_save_tex) + monkeypatch.setattr(Report, 'save_pdf', fake_save_pdf) + monkeypatch.setattr(pdf_compiler, 'compile_pdf_report', fake_compile_pdf_report) + + assert report._save_configured() == [tex_path, pdf_path] + assert calls == ['tex', ('pdf', tex_path)] + + +def test_save_configured_returns_intended_missing_pdf(tmp_path, monkeypatch): + from easydiffraction.project.categories.report.default import Report + from easydiffraction.report import pdf_compiler + + tex_path = tmp_path / 'reports' / 'tex' / 'demo.tex' + pdf_path = tmp_path / 'reports' / 'demo.pdf' + report = Report() + report.html = False + report.tex = True + report.pdf = True + + def fake_save_tex(self): + del self + return tex_path + + def fake_compile_pdf_report(path): + assert path == tex_path + return pdf_path + + monkeypatch.setattr(Report, 'save_tex', fake_save_tex) + monkeypatch.setattr(pdf_compiler, 'compile_pdf_report', fake_compile_pdf_report) + + assert report._save_configured() == [tex_path, pdf_path] diff --git a/tests/unit/easydiffraction/project/categories/report/test_factory.py b/tests/unit/easydiffraction/project/categories/report/test_factory.py new file mode 100644 index 000000000..99a7a1e75 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/report/test_factory.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import pytest + + +def test_report_factory_default_and_create(): + from easydiffraction.project.categories.report.default import Report + from easydiffraction.project.categories.report.factory import ReportFactory + + assert ReportFactory.default_tag() == 'default' + assert 'default' in ReportFactory.supported_tags() + + report = ReportFactory.create('default') + + assert isinstance(report, Report) + + +def test_report_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.report.factory import ReportFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + ReportFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py index 522ab774e..153fd5687 100644 --- a/tests/unit/easydiffraction/project/test_project.py +++ b/tests/unit/easydiffraction/project/test_project.py @@ -80,8 +80,6 @@ def test_project_exposes_chart_table_and_display_facades(): assert isinstance(project.display, ProjectDisplay) assert isinstance(project.report, Report) assert hasattr(project.report, 'save') - assert hasattr(project.report, 'check') - assert hasattr(project.report, 'show_report') def test_apply_params_from_csv_resolves_relative_file_paths(tmp_path): @@ -190,26 +188,20 @@ def test_project_save_report_writes_submission_cif(tmp_path): project = Project(name='report_project') project.save_as(str(tmp_path / 'proj')) - project.save(report=True) + project.report.cif = True + project.save() assert not (tmp_path / 'proj' / 'summary.cif').exists() assert (tmp_path / 'proj' / 'reports' / 'report_project.cif').is_file() -def test_project_save_report_check_runs_validation(tmp_path, monkeypatch): +def test_project_save_rejects_removed_report_keyword(tmp_path): + import pytest + from easydiffraction.project.project import Project project = Project(name='checked_report') - checked_paths = [] - - def fake_check(*, path=None): - checked_paths.append(path) - project.save_as(str(tmp_path / 'proj')) - monkeypatch.setattr(project.report, 'check', fake_check) - - project.save(report=True, check=True) - assert checked_paths == [ - tmp_path / 'proj' / 'reports' / 'checked_report.cif', - ] + with pytest.raises(TypeError, match="unexpected keyword argument 'report'"): + project.save(report=True) diff --git a/tests/unit/easydiffraction/project/test_project_config.py b/tests/unit/easydiffraction/project/test_project_config.py index f35d814a8..c921b3199 100644 --- a/tests/unit/easydiffraction/project/test_project_config.py +++ b/tests/unit/easydiffraction/project/test_project_config.py @@ -9,6 +9,7 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): from easydiffraction.core.category_owner import CategoryOwner from easydiffraction.project.categories.chart import Chart + from easydiffraction.project.categories.report import Report from easydiffraction.project.categories.table import Table from easydiffraction.project.project_config import ProjectConfig from easydiffraction.project.project_info import ProjectInfo @@ -18,9 +19,11 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): assert isinstance(config, CategoryOwner) assert isinstance(config.info, ProjectInfo) assert isinstance(config.chart, Chart) + assert isinstance(config.report, Report) assert isinstance(config.table, Table) assert config.info._parent is config assert config.chart._parent is config + assert config.report._parent is config assert config.table._parent is config assert config.info.name == 'beer' assert config.info.title == 'Beer title' @@ -30,10 +33,17 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): assert isinstance(config.info.last_modified, datetime.datetime) assert config.verbosity._parent is config assert config.verbosity.fit.value == 'full' - assert config.categories == [config.info, config.chart, config.table, config.verbosity] + assert config.categories == [ + config.info, + config.chart, + config.report, + config.table, + config.verbosity, + ] assert config.parameters == ( config.info.parameters + config.chart.parameters + + config.report.parameters + config.table.parameters + config.verbosity.parameters ) @@ -53,6 +63,11 @@ def test_project_config_as_cif_has_project_chart_and_table_sections_without_data assert '_project.created' in cif_text assert '_project.last_modified' in cif_text assert '_chart.type' in cif_text + assert '_report.cif' in cif_text + assert '_report.html' in cif_text + assert '_report.tex' in cif_text + assert '_report.pdf' in cif_text + assert '_report.html_offline' in cif_text assert '_table.type' in cif_text assert '_chart.type auto' in cif_text assert '_table.type auto' in cif_text @@ -69,6 +84,7 @@ def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path): assert not project_cif.startswith('data_') assert '_chart.type auto' in project_cif + assert '_report.cif false' in project_cif assert '_table.type auto' in project_cif assert '_verbosity.fit full' in project_cif @@ -91,6 +107,7 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path): assert not project_cif.startswith('data_') assert '_project.id beer' in project_cif assert '_chart.type asciichartpy' in project_cif + assert '_report.cif false' in project_cif assert '_table.type rich' in project_cif assert '_verbosity.fit full' in project_cif diff --git a/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py b/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py index 1f9de101f..6ba24a06b 100644 --- a/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py +++ b/tests/unit/easydiffraction/project/test_project_load_and_summary_wrap.py @@ -23,29 +23,3 @@ def test_project_load_reads_project_info(tmp_path): assert loaded.info.title == 'My Title' assert loaded.info.description == 'A description' assert loaded.info.path is not None - - -def test_report_show_project_info_wraps_description(capsys): - from easydiffraction.report.report import Report - - long_desc = ' '.join(['desc'] * 50) # long text to trigger wrapping - - class Info: - title = 'T' - description = long_desc - - class Project: - def __init__(self): - self.info = Info() - - report = Report(Project()) - report.show_project_info() - out = capsys.readouterr().out - # Title and Description paragraph headers present - assert 'PROJECT INFO' in out - assert 'Title' in out - assert 'Description' in out - # Ensure multiple lines of description were printed (wrapped) - # Keep the exact word count and verify the presence of line breaks in the description block - assert out.count('desc') == 50 # all words are present exactly once - assert '\ndesc ' in out or ' desc\n' in out # wrapped across lines diff --git a/tests/unit/easydiffraction/project/test_project_save.py b/tests/unit/easydiffraction/project/test_project_save.py index 6d851216a..f061aa168 100644 --- a/tests/unit/easydiffraction/project/test_project_save.py +++ b/tests/unit/easydiffraction/project/test_project_save.py @@ -8,6 +8,7 @@ def test_project_save_uses_cwd_when_no_explicit_path(monkeypatch, tmp_path, caps monkeypatch.chdir(tmp_path) p = Project() + p.report.html = False p.save_as(str(tmp_path)) out = capsys.readouterr().out # It should announce saving and create the three core files @@ -28,6 +29,7 @@ def test_project_save_as_writes_core_files(tmp_path, monkeypatch): monkeypatch.setattr(Analysis, 'as_cif', property(lambda self: 'analysis')) p = Project(name='p1') + p.report.html = False target = tmp_path / 'proj_dir' p.save_as(str(target)) diff --git a/tests/unit/easydiffraction/project/test_publication_loader.py b/tests/unit/easydiffraction/project/test_publication_loader.py new file mode 100644 index 000000000..565c5afab --- /dev/null +++ b/tests/unit/easydiffraction/project/test_publication_loader.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import pytest + + +def test_load_publication_reads_toml_metadata(tmp_path): + from easydiffraction.project.categories.publication.default import Publication + from easydiffraction.project.publication_loader import load_publication + + path = tmp_path / 'publication.toml' + path.write_text( + """ +journal_name_full = "Journal of Testing" +body_title = "Refinement report" +body_keywords = ["diffraction", "neutron"] + +[[authors]] +name = "Ada Lovelace" +address = "London" +""".lstrip(), + encoding='utf-8', + ) + publication = Publication() + + load_publication(publication, path) + + assert publication.journal.name_full.value == 'Journal of Testing' + assert publication.body.title.value == 'Refinement report' + assert publication.body.keywords == ['diffraction', 'neutron'] + assert len(publication.authors) == 1 + assert publication.authors[0].name.value == 'Ada Lovelace' + assert publication.authors[0].address.value == 'London' + + +def test_load_publication_rejects_unknown_extension(tmp_path): + from easydiffraction.project.categories.publication.default import Publication + from easydiffraction.project.publication_loader import load_publication + + path = tmp_path / 'publication.txt' + path.write_text('body_title = "x"', encoding='utf-8') + + with pytest.raises(ValueError, match='Unsupported publication-info format'): + load_publication(Publication(), path) + + +def test_load_publication_rejects_invalid_author_shape(tmp_path): + from easydiffraction.project.categories.publication.default import Publication + from easydiffraction.project.publication_loader import load_publication + + path = tmp_path / 'publication.json' + path.write_text('{"authors": [{"address": "missing name"}]}', encoding='utf-8') + + with pytest.raises(ValueError, match=r'authors\[0\]\.name'): + load_publication(Publication(), path) diff --git a/tests/unit/easydiffraction/report/test_check.py b/tests/unit/easydiffraction/report/test_check.py deleted file mode 100644 index 773a6c83a..000000000 --- a/tests/unit/easydiffraction/report/test_check.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Tests for IUCr report validation helpers.""" - -from __future__ import annotations - - -def test_check_report_surfaces_parse_errors(tmp_path): - from easydiffraction.report.check import check_report - - result = check_report(tmp_path / 'missing.cif') - - assert result.ok is False - assert len(result.errors) == 1 - assert result.errors[0].startswith('Failed to parse report CIF:') - - -def test_check_report_warns_for_unknown_non_extension_tags(tmp_path): - from easydiffraction.report.check import check_report - - report_path = tmp_path / 'report.cif' - report_path.write_text( - 'data_test\n' - '_audit.creation_method EasyDiffraction\n' - '_easydiffraction_custom.value 1\n' - '_unknown.bad 2', - encoding='utf-8', - ) - dictionary_path = tmp_path / 'core.dic' - dictionary_path.write_text( - 'data_core\nsave__audit.creation_method\nsave_', - encoding='utf-8', - ) - - result = check_report(report_path, dictionary_paths=(dictionary_path,)) - - assert 'Unknown IUCr tag: _unknown.bad' in result.warnings - assert all('_easydiffraction_custom.value' not in warning for warning in result.warnings) diff --git a/tests/unit/easydiffraction/report/test_data_context.py b/tests/unit/easydiffraction/report/test_data_context.py new file mode 100644 index 000000000..8034e756f --- /dev/null +++ b/tests/unit/easydiffraction/report/test_data_context.py @@ -0,0 +1,478 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for report data-context construction.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np + + +class _Descriptor: + def __init__(self, value): + self.value = value + + +class _XDescriptor: + name = 'intensity_calc' + units = 'none' + + @staticmethod + def resolve_display_name(context): + del context + return 'I²calc' + + @staticmethod + def resolve_display_units(context): + del context + return '' + + +class _TwoThetaDescriptor: + name = 'two_theta' + units = 'degrees' + + @staticmethod + def resolve_display_name(context): + del context + return '2θ' + + @staticmethod + def resolve_display_units(context): + del context + return 'deg' + + +def _parameter(name, value, uncertainty): + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + + parameter = Parameter( + name=name, + value_spec=AttributeSpec(default=0.0), + cif_handler=CifHandler(names=[f'_{name}']), + ) + parameter.value = value + parameter.free = True + parameter.uncertainty = uncertainty + return parameter + + +def _structure() -> SimpleNamespace: + return SimpleNamespace( + name='phase', + space_group=SimpleNamespace( + name_h_m=_Descriptor('P 1'), + crystal_system=_Descriptor('triclinic'), + ), + cell=SimpleNamespace( + length_a=_parameter('length_a', 11.98509310, 0.03069505), + length_b=_parameter('length_b', 2.0, None), + length_c=_parameter('length_c', 3.0, None), + angle_alpha=_parameter('angle_alpha', 90.0, None), + angle_beta=_parameter('angle_beta', 90.0, None), + angle_gamma=_parameter('angle_gamma', 90.0, None), + ), + atom_sites=[ + SimpleNamespace( + label=_Descriptor('Si1'), + type_symbol=_Descriptor('Si'), + fract_x=_parameter('fract_x', 11.98509310, 0.03069505), + fract_y=_parameter('fract_y', 0.0, None), + fract_z=_parameter('fract_z', 0.0, None), + occupancy=_parameter('occupancy', 1.0, None), + adp_type=_Descriptor('Uani'), + adp_iso=_parameter('adp_iso', 0.00658189, 0.00014), + ) + ], + atom_site_aniso=[ + SimpleNamespace( + label=_Descriptor('Si1'), + adp_11=_parameter('adp_11', 0.00658189, 0.00014), + adp_22=_parameter('adp_22', 0.00488144, 0.00029), + adp_33=_parameter('adp_33', 0.00488144, None), + adp_12=_parameter('adp_12', -0.00048176, 0.00025), + adp_13=_parameter('adp_13', 0.0, None), + adp_23=_parameter('adp_23', 0.00188994, 0.00013), + ) + ], + ) + + +def _experiment() -> SimpleNamespace: + return SimpleNamespace( + name='heidi', + type=SimpleNamespace( + sample_form=_Descriptor('single crystal'), + beam_mode=_Descriptor('constant wavelength'), + radiation_probe=_Descriptor('neutron'), + scattering_type=_Descriptor('bragg'), + ), + calculator=SimpleNamespace(type=_Descriptor('cryspy')), + diffrn=SimpleNamespace(), + measured_range=None, + x_descriptor=_XDescriptor(), + fit_data_arrays=lambda: { + 'x': np.array([10.0, 20.0]), + 'meas': np.array([11.0, 19.0]), + 'meas_su': np.array([0.5, 0.7]), + 'calc': np.array([10.0, 20.0]), + 'diff': np.array([1.0, -1.0]), + 'bkg': None, + }, + refln=SimpleNamespace( + intensity_meas=np.array([11.0, 19.0]), + intensity_calc=np.array([10.0, 20.0]), + intensity_meas_su=np.array([0.5, 0.7]), + ), + ) + + +def _project() -> SimpleNamespace: + return SimpleNamespace( + name='demo', + info=SimpleNamespace(title=_Descriptor('Demo'), description=_Descriptor(None)), + structures={'phase': _structure()}, + experiments={'heidi': _experiment()}, + analysis=SimpleNamespace( + fit_result=SimpleNamespace(), + software=SimpleNamespace(), + constraints=[], + ), + publication=SimpleNamespace(), + ) + + +def test_report_data_context_builds_fit_data(): + from easydiffraction.report.data_context import build_report_data_context + + context = build_report_data_context(_project()) + + fit_data = context['experiments'][0]['fit_data'] + assert fit_data['axes_labels'] == ['I²calc', 'I²meas'] + assert list(fit_data['series']['meas']['su']) == [0.5, 0.7] + assert list(fit_data['series']['calc']['values']) == [10.0, 20.0] + assert list(fit_data['series']['diff']['values']) == [1.0, -1.0] + assert fit_data['bragg_tick_sets'] == () + + +def test_report_data_context_builds_powder_bragg_tick_sets(): + from easydiffraction.report.data_context import build_report_data_context + + experiment = _experiment() + experiment.type.sample_form = _Descriptor('powder') + experiment.x_descriptor = _TwoThetaDescriptor() + experiment.fit_data_arrays = lambda: { + 'x': np.array([1.0, 2.0]), + 'meas': np.array([11.0, 19.0]), + 'meas_su': np.array([0.5, 0.7]), + 'calc': np.array([10.0, 20.0]), + 'diff': np.array([1.0, -1.0]), + 'bkg': np.array([2.0, 2.5]), + } + experiment.refln = SimpleNamespace( + phase_id=np.array(['phase-a']), + two_theta=np.array([1.5]), + index_h=np.array([1]), + index_k=np.array([0]), + index_l=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ) + project = _project() + project.experiments = {'hrpt': experiment} + + context = build_report_data_context(project) + + tick_sets = context['experiments'][0]['fit_data']['bragg_tick_sets'] + assert [tick_set.phase_id for tick_set in tick_sets] == ['phase-a'] + assert list(tick_sets[0].x) == [1.5] + + +def test_report_data_context_preserves_structure_uncertainties(): + from easydiffraction.report.data_context import build_report_data_context + + context = build_report_data_context(_project()) + structure = context['structures'][0] + + assert structure['cell']['length_a'] == '11.985(31)' + assert structure['atom_sites'][0]['fract_x'] == '11.985(31)' + assert structure['atom_sites'][0]['adp_iso'] == '0.00658(14)' + assert structure['atom_site_aniso'][0]['adp_12'] == '-0.00048(25)' + + +def test_report_category_context_keeps_numeric_string_ids_as_text(): + from easydiffraction.datablocks.experiment.categories.background.line_segment import ( + LineSegmentBackground, + ) + from easydiffraction.report.data_context import _collection_category_context + + category = LineSegmentBackground() + category.create(id='10', x=10.0, y=2.0) + category.create(id='30', x=30.0, y=3.0) + + context = _collection_category_context(category) + + assert context['colspec'] == 'cS[table-format=2.0]S[table-format=1.0]' + assert [(column['latex_label'], column['numeric']) for column in context['columns']] == [ + ('ID', False), + ('$x$', True), + ('Intensity', True), + ] + assert context['rows'][0]['cells'][1]['number'] == { + 'left': '10', + 'right': '', + 'has_decimal': False, + 'left_ch': 2, + 'right_ch': 1, + } + assert context['rows'][0]['cells'][2]['number'] == { + 'left': '2', + 'right': '', + 'has_decimal': True, + 'left_ch': 1, + 'right_ch': 1, + } + + +def test_report_key_value_colspec_uses_numeric_table_format(): + from easydiffraction.report.data_context import _key_value_colspec + + rows = [ + {'value': '11.985(31)', 'numeric': True}, + {'value': '2.', 'numeric': True}, + {'value': 'not refined', 'numeric': False}, + ] + + assert _key_value_colspec(rows) == 'lS[table-format=2.3(2)]' + + +def test_report_loop_rows_skip_identifier_only_rows(): + from easydiffraction.report.data_context import _loop_row_has_report_values + + empty_row = { + 'cells': [ + {'value': '1'}, + {'value': ''}, + {'value': None}, + ], + } + populated_row = { + 'cells': [ + {'value': '1'}, + {'value': '10.5'}, + {'value': None}, + ], + } + + assert not _loop_row_has_report_values(empty_row) + assert _loop_row_has_report_values(populated_row) + + +def test_report_data_loop_rows_are_display_truncated(): + from easydiffraction.report.data_context import _REPORT_LOOP_DISPLAY_LIMIT + from easydiffraction.report.data_context import _truncate_loop_rows + + rows = [ + { + 'cells': [ + {'value': str(index), 'numeric': False, 'number': None}, + {'value': float(index), 'numeric': False, 'number': None}, + ], + } + for index in range(_REPORT_LOOP_DISPLAY_LIMIT + 5) + ] + + truncated = _truncate_loop_rows(rows) + + assert len(truncated) == _REPORT_LOOP_DISPLAY_LIMIT + 1 + assert truncated[0]['cells'][0]['value'] == '0' + assert truncated[9]['cells'][0]['value'] == '9' + assert truncated[10]['cells'][0]['value'] == '...' + assert truncated[10]['cells'][1]['value'] == '' + assert truncated[11]['cells'][0]['value'] == '15' + assert truncated[-1]['cells'][0]['value'] == '24' + + +def test_report_pd_data_columns_use_compact_labels(): + from easydiffraction.datablocks.experiment.categories.data.bragg_pd import ( + PdCwlData, + ) + from easydiffraction.report.data_context import _collection_category_context + + category = PdCwlData() + category._create_items_set_xcoord_and_id(np.array([10.0])) + + context = _collection_category_context(category) + + assert [(column['latex_label'], column['html_label']) for column in context['columns']] == [ + (r'$2\theta$', r'\(2\theta\)'), + ('ID', 'ID'), + (r'$d$', r'\(d\)'), + (r'$I_{\mathrm{meas}}$', r'\(I_{\mathrm{meas}}\)'), + ( + r'$\sigma(I_{\mathrm{meas}})$', + r'\(\sigma(I_{\mathrm{meas}})\)', + ), + (r'$I_{\mathrm{calc}}$', r'\(I_{\mathrm{calc}}\)'), + (r'$I_{\mathrm{bkg}}$', r'\(I_{\mathrm{bkg}}\)'), + ('Status', 'Status'), + ] + + +def test_report_powder_refln_columns_use_compact_labels(): + from easydiffraction.analysis.calculators.base import PowderReflnRecord + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, + ) + from easydiffraction.report.data_context import _collection_category_context + + category = PowderCwlReflnData() + category._replace_from_records([ + PowderReflnRecord( + phase_id='phase', + d_spacing=1.0, + sin_theta_over_lambda=0.5, + index_h=1, + index_k=0, + index_l=1, + f_calc=2.0, + f_squared_calc=4.0, + two_theta=20.0, + ) + ]) + + context = _collection_category_context(category) + + assert [(column['latex_label'], column['html_label']) for column in context['columns']] == [ + ('ID', 'ID'), + ('Phase', 'Phase'), + (r'$d$', r'\(d\)'), + (r'$\sin\theta/\lambda$', r'\(\sin\theta/\lambda\)'), + (r'$h$', r'\(h\)'), + (r'$k$', r'\(k\)'), + (r'$l$', r'\(l\)'), + (r'$F_{\mathrm{calc}}$', r'\(F_{\mathrm{calc}}\)'), + (r'$F^2_{\mathrm{calc}}$', r'\(F^2_{\mathrm{calc}}\)'), + (r'$2\theta$', r'\(2\theta\)'), + ] + + +def test_report_atom_site_adp_column_uses_active_b_u_labels(): + from easydiffraction.datablocks.structure.item.base import Structure + from easydiffraction.report.data_context import _collection_category_context + + structure = Structure(name='phase') + structure.atom_sites.create( + label='Si1', + type_symbol='Si', + adp_type='Biso', + adp_iso=0.5, + ) + structure.atom_sites.create( + label='O1', + type_symbol='O', + adp_type='Uiso', + adp_iso=0.006, + ) + + context = _collection_category_context(structure.atom_sites) + adp_column = next(column for column in context['columns'] if column['name'] == 'adp_iso') + + assert adp_column['label'] == 'Biso / Uiso' + assert adp_column['html_label'] == r'\(B_{\mathrm{iso}}\) / \(U_{\mathrm{iso}}\)' + assert adp_column['latex_label'] == r'$B_{\mathrm{iso}}$ / $U_{\mathrm{iso}}$' + + +def test_report_atom_site_aniso_adp_column_uses_active_b_label(): + from easydiffraction.datablocks.structure.item.base import Structure + from easydiffraction.report.data_context import _collection_category_context + + structure = Structure(name='phase') + structure.atom_sites.create( + label='Si1', + type_symbol='Si', + adp_iso=0.5, + ) + structure.atom_sites['Si1'].adp_type = 'Bani' + structure._sync_atom_site_aniso() + + context = _collection_category_context(structure.atom_site_aniso) + adp_column = next(column for column in context['columns'] if column['name'] == 'adp_11') + + assert adp_column['label'] == 'B11' + assert adp_column['html_label'] == r'\(B_{11}\)' + assert adp_column['latex_label'] == r'$B_{11}$' + + +def test_report_number_parts_split_decimal_and_uncertainty_text(): + from easydiffraction.report.data_context import _number_parts + + assert _number_parts('0.584(20)') == { + 'left': '0', + 'right': '584(20)', + 'has_decimal': True, + } + assert _number_parts('.5') == { + 'left': '0', + 'right': '5', + 'has_decimal': True, + } + assert _number_parts('1.') == { + 'left': '1', + 'right': '', + 'has_decimal': True, + } + assert _number_parts('1') == { + 'left': '1', + 'right': '', + 'has_decimal': False, + } + + +def test_report_descriptor_rows_normalize_angstrom_for_mathjax(): + from easydiffraction.core.display_handler import DisplayHandler + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + from easydiffraction.report.data_context import _descriptor_rows + + parameter = Parameter( + name='adp_iso', + value_spec=AttributeSpec(default=0.0), + display_handler=DisplayHandler( + latex_name=r'$U_{\mathrm{iso}}$', + latex_units=r'\AA$^2$', + ), + cif_handler=CifHandler(names=['_atom_site.U_iso_or_equiv']), + ) + + rows = _descriptor_rows([parameter]) + + assert rows[0]['html_label'] == r'\(U_{\mathrm{iso}}\)' + assert rows[0]['html_units'] == r'\(\mathring{\mathrm{A}}^2\)' + + +def test_report_descriptor_rows_preserve_mixed_mathjax_label_text(): + from easydiffraction.core.display_handler import DisplayHandler + from easydiffraction.core.validation import AttributeSpec + from easydiffraction.core.variable import Parameter + from easydiffraction.io.cif.handler import CifHandler + from easydiffraction.report.data_context import _descriptor_rows + + parameter = Parameter( + name='twotheta_offset', + value_spec=AttributeSpec(default=0.0), + display_handler=DisplayHandler( + latex_name=r'$2\theta$ offset', + latex_units=r'$^\circ$', + ), + cif_handler=CifHandler(names=['_instr.2theta_offset']), + ) + + rows = _descriptor_rows([parameter]) + + assert rows[0]['html_label'] == r'\(2\theta\) offset' + assert rows[0]['html_units'] == r'\(\mathrm{deg}\)' diff --git a/tests/unit/easydiffraction/report/test_enums.py b/tests/unit/easydiffraction/report/test_enums.py new file mode 100644 index 000000000..751cb57dc --- /dev/null +++ b/tests/unit/easydiffraction/report/test_enums.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_report_format_enum_values(): + from easydiffraction.report.enums import ReportFormatEnum + + assert [member.value for member in ReportFormatEnum] == [ + 'cif', + 'html', + 'tex', + 'pdf', + ] diff --git a/tests/unit/easydiffraction/report/test_fit_plot.py b/tests/unit/easydiffraction/report/test_fit_plot.py new file mode 100644 index 000000000..c041f1948 --- /dev/null +++ b/tests/unit/easydiffraction/report/test_fit_plot.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for shared report fit-plot helpers.""" + +from __future__ import annotations + +import pytest + + +def test_fit_plot_styles_reuse_plotly_series_conventions(): + from easydiffraction.report.fit_plot import fit_bragg_tick_styles + from easydiffraction.report.fit_plot import fit_plot_styles + + styles = fit_plot_styles() + bragg_styles = fit_bragg_tick_styles() + + assert styles['meas']['name'] == 'Measured (Imeas)' + assert styles['meas']['mode'] == 'lines+markers' + assert styles['meas']['rgb'] == '31,119,180' + assert styles['meas']['line_width'] == 2.0 + assert styles['meas']['legend_rank'] == 10 + assert styles['diff']['name'] == 'Residual (Imeas - Icalc)' + assert bragg_styles[0]['color_name'] == 'ed_bragg_0' + assert bragg_styles[0]['rgb'] == '255,127,14' + + +def test_fit_plot_ranges_match_plotly_main_intensity_margin(): + from easydiffraction.report.fit_plot import fit_plot_ranges + + fit_data = { + 'x': {'values': [1.0, 2.0]}, + 'series': { + 'meas': {'values': [10.0, 30.0]}, + 'calc': {'values': [12.0, 20.0]}, + 'diff': {'values': [-2.0, 10.0]}, + 'bkg': {'values': [5.0, 15.0]}, + }, + } + + ranges = fit_plot_ranges(fit_data) + + assert ranges['x_min'] == pytest.approx(1.0) + assert ranges['x_max'] == pytest.approx(2.0) + assert ranges['y_min'] == pytest.approx(3.75) + assert ranges['y_max'] == pytest.approx(31.25) + assert ranges['residual_y_min'] == pytest.approx(-3.4375) + assert ranges['residual_y_max'] == pytest.approx(3.4375) diff --git a/tests/unit/easydiffraction/report/test_html_renderer.py b/tests/unit/easydiffraction/report/test_html_renderer.py new file mode 100644 index 000000000..d07d6bd40 --- /dev/null +++ b/tests/unit/easydiffraction/report/test_html_renderer.py @@ -0,0 +1,370 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for HTML report rendering.""" + +from __future__ import annotations + +import numpy as np + + +def _field(label: str, units: str = '') -> dict[str, str]: + return {'label': label, 'units': units} + + +def _category_row( + name: str, + value: object, + *, + label: str | None = None, + html_label: str | None = None, + html_units: str = '', + numeric: bool = False, + number: dict[str, object] | None = None, +) -> dict[str, object]: + return { + 'name': name, + 'label': label or name, + 'html_label': html_label or label or name, + 'html_units': html_units, + 'value': value, + 'numeric': numeric, + 'number': number, + } + + +def _category_column( + name: str, + *, + label: str | None = None, + html_label: str | None = None, + html_units: str = '', + numeric: bool = False, +) -> dict[str, object]: + return { + 'name': name, + 'label': label or name, + 'html_label': html_label or label or name, + 'html_units': html_units, + 'numeric': numeric, + } + + +def _category_cell( + value: object, + *, + numeric: bool = False, + number: dict[str, object] | None = None, +) -> dict[str, object]: + return {'value': value, 'numeric': numeric, 'number': number} + + +def _number( + left: str, + right: str, + *, + has_decimal: bool = True, + left_ch: int = 1, + right_ch: int = 1, +) -> dict[str, object]: + return { + 'left': left, + 'right': right, + 'has_decimal': has_decimal, + 'left_ch': left_ch, + 'right_ch': right_ch, + } + + +def _context() -> dict[str, object]: + return { + 'project': { + 'name': 'report_project', + 'title': 'Report Project', + 'description': 'Project description.', + 'n_phases': 1, + 'n_experiments': 0, + }, + 'publication': { + 'body': {'title': '', 'abstract': '', 'synopsis': '', 'keywords': ''}, + 'authors': [], + 'journal': {'name_full': '', 'year': '', 'paper_doi': ''}, + 'contact_author': {'name': '', 'email': ''}, + }, + 'metadata': { + 'generated_at': '2026-05-26T00:00:00Z', + 'easydiffraction_version': '0.0', + }, + 'refinement': { + 'fit_result': { + 'reduced_chi_square': None, + 'r_factor_all': None, + 'wr_factor_all': None, + }, + 'parameters': {'free': 0, 'total': 0}, + 'constraints': 0, + 'rows': [ + { + 'label': 'Reduced chi-square', + 'value': '1.23', + 'numeric': True, + 'number': _number('1', '23', right_ch=2), + }, + ], + }, + 'software': { + 'framework': {'name': 'EasyDiffraction', 'version': '0.0'}, + 'calculator': {'name': 'cryspy', 'version': '0.0'}, + 'minimizer': {'name': 'lmfit', 'version': '0.0'}, + }, + 'analysis': { + 'software': { + 'framework': {'name': 'EasyDiffraction', 'version': '0.0'}, + 'calculator': {'name': 'cryspy', 'version': '0.0'}, + 'minimizer': {'name': 'lmfit', 'version': '0.0'}, + }, + 'categories': [ + { + 'kind': 'item', + 'code': 'fit_result', + 'title': 'fit_result', + 'has_numeric_values': True, + 'value_column_numeric': True, + 'colspec': 'lS[table-format=1.2]', + 'rows': [ + { + 'label': 'Reduced chi-square', + 'value': '1.23', + 'numeric': True, + 'number': _number('1', '23', right_ch=2), + }, + ], + }, + ], + }, + 'structures': [ + { + 'id': 'phase', + 'space_group': 'P 1', + 'crystal_system': 'triclinic', + 'cell': { + 'length_a': '11.985(31)', + 'length_b': '2.()', + 'length_c': '3.()', + 'angle_alpha': '90.()', + 'angle_beta': '90.()', + 'angle_gamma': '90.()', + }, + 'cell_display': { + 'length_a': _field('a', 'A'), + 'length_b': _field('b', 'A'), + 'length_c': _field('c', 'A'), + 'angle_alpha': _field('alpha', 'deg'), + 'angle_beta': _field('beta', 'deg'), + 'angle_gamma': _field('gamma', 'deg'), + }, + 'atom_sites': [ + { + 'label': 'Si1', + 'type_symbol': 'Si', + 'fract_x': '11.985(31)', + 'fract_y': '0.', + 'fract_z': '0.', + 'occupancy': '1.', + 'adp_iso': '0.00658(14)', + } + ], + 'atom_site_display': { + 'fract_x': _field('x'), + 'fract_y': _field('y'), + 'fract_z': _field('z'), + 'adp_iso': _field('Uiso', 'A^2'), + }, + 'atom_site_aniso': [ + { + 'label': 'Si1', + 'adp_11': '0.00658(14)', + 'adp_22': '0.00488(29)', + 'adp_33': '0.00488144', + 'adp_12': '-0.00048(25)', + 'adp_13': '0.', + 'adp_23': '0.00189(13)', + } + ], + 'atom_site_aniso_display': { + 'adp_11': _field('U11', 'A^2'), + 'adp_22': _field('U22', 'A^2'), + 'adp_33': _field('U33', 'A^2'), + 'adp_12': _field('U12', 'A^2'), + 'adp_13': _field('U13', 'A^2'), + 'adp_23': _field('U23', 'A^2'), + }, + 'categories': [ + { + 'kind': 'item', + 'title': 'cell', + 'rows': [ + _category_row( + 'length_a', + '11.985(31)', + html_label='a', + html_units='Å', + numeric=True, + number=_number( + '11', + '985(31)', + left_ch=2, + right_ch=7, + ), + ), + ], + }, + { + 'kind': 'loop', + 'title': 'atom_site', + 'scalar_rows': [], + 'columns': [ + _category_column('label'), + _category_column( + 'adp_iso', + html_label=r'\(U_{\mathrm{iso}}\)', + html_units=r'\(\mathring{\mathrm{A}}^2\)', + numeric=True, + ), + ], + 'rows': [ + { + 'cells': [ + _category_cell('Si1'), + _category_cell( + '0.00658(14)', + numeric=True, + number=_number( + '0', + '00658(14)', + right_ch=9, + ), + ), + ], + }, + ], + }, + { + 'kind': 'loop', + 'title': 'atom_site_aniso', + 'scalar_rows': [], + 'columns': [ + _category_column('label'), + _category_column( + 'adp_12', + html_label=r'\(U_{12}\)', + numeric=True, + ), + ], + 'rows': [ + { + 'cells': [ + _category_cell('Si1'), + _category_cell( + '-0.00048(25)', + numeric=True, + number=_number( + '-0', + '00048(25)', + left_ch=2, + right_ch=9, + ), + ), + ], + }, + ], + }, + ], + } + ], + 'experiments': [], + 'figures': {'fit_per_experiment': {}}, + } + + +def test_render_html_report_preserves_structure_uncertainty_text(): + from easydiffraction.report.html_renderer import render_html_report + + html = render_html_report(_context()) + + assert '11.985(31)' in html + assert '0.00658(14)' in html + assert '-0.00048(25)' in html + assert '

Publication

' not in html + assert '

Project Description

' in html + assert '

Project Summary

' in html + assert '
' in html + assert 'Short name' in html + assert 'Short name' not in html + assert 'margin-right: 0.5em;' in html + assert '--wide-colsep: 3pt;' in html + assert 'class="numeric"' in html + assert 'class="number"' in html + assert '--number-left: 2ch; --number-right: 7ch' in html + assert 'aria-label="1.23"' in html + + +def test_render_html_report_uses_plotly_fit_style_order(): + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.report.html_renderer import render_html_report + + context = _context() + context['project']['n_experiments'] = 1 + context['experiments'] = [ + { + 'id': 'hrpt', + 'type': { + 'sample_form': 'powder', + 'radiation_probe': 'neutron', + 'beam_mode': 'constant wavelength', + 'scattering_type': 'bragg', + }, + 'calculator': {'type': 'cryspy'}, + 'diffrn': {'ambient_temperature': '', 'ambient_pressure': ''}, + 'diffrn_display': { + 'ambient_temperature': _field('Temperature', 'K'), + 'ambient_pressure': _field('Pressure', 'kPa'), + }, + 'fit_data': { + 'x': {'values': [1.0, 2.0], 'display_name': '2theta'}, + 'axes_labels': ['2θ (deg)', 'Intensity (arb. units)'], + 'series': { + 'meas': {'values': [10.0, 11.0], 'su': [0.1, 0.2]}, + 'calc': {'values': [10.0, 12.0]}, + 'diff': {'values': [0.0, -1.0]}, + 'bkg': {'values': [5.0, 5.5]}, + }, + 'bragg_tick_sets': ( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + ), + }, + } + ] + + html = render_html_report(context) + + measured = html.index('"name":"Measured (Imeas)"') + background = html.index('"name":"Background (Ibkg)"') + calculated = html.index('"name":"Total calculated (Icalc)"') + residual = html.index('"name":"Residual (Imeas - Icalc)"') + assert measured < background < calculated < residual + assert '"legendrank":10' in html + assert '"legendrank":20' in html + assert '"legendrank":30' in html + assert '"legendrank":40' in html + assert '"error_y"' in html + assert '"color":"rgb(31, 119, 180)"' in html + assert '"name":"Bragg peaks: phase-a"' in html + assert '"yaxis3"' in html diff --git a/tests/unit/easydiffraction/report/test_pdf_compiler.py b/tests/unit/easydiffraction/report/test_pdf_compiler.py new file mode 100644 index 000000000..d5159673c --- /dev/null +++ b/tests/unit/easydiffraction/report/test_pdf_compiler.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import subprocess # noqa: S404 + + +def test_compile_pdf_runs_from_tex_dir_with_absolute_output(tmp_path, monkeypatch): + from easydiffraction.report import pdf_compiler + + reports_dir = tmp_path / 'reports' + tex_dir = reports_dir / 'tex' + tex_dir.mkdir(parents=True) + tex_path = tex_dir / 'report.tex' + pdf_path = reports_dir / 'report.pdf' + tex_path.write_text(r'\documentclass{article}', encoding='utf-8') + calls = [] + + def fake_run( + command, + *, + cwd, + env, + text, + capture_output, + check, + ): + calls.append( + { + 'command': command, + 'cwd': cwd, + 'env': env, + 'text': text, + 'capture_output': capture_output, + 'check': check, + }, + ) + pdf_path.write_text('%PDF', encoding='utf-8') + return subprocess.CompletedProcess(command, 0, '', '') + + monkeypatch.setattr(pdf_compiler.subprocess, 'run', fake_run) + + assert pdf_compiler._compile_pdf(('tectonic', 'tectonic'), tex_path, pdf_path) is None + + assert calls[0]['command'] == [ + 'tectonic', + '--outdir', + str(reports_dir.resolve()), + 'report.tex', + ] + assert calls[0]['cwd'] == tex_dir.resolve() + + +def test_compile_pdf_report_skips_tectonic_runtime_failure(tmp_path, monkeypatch): + from easydiffraction.report import pdf_compiler + + reports_dir = tmp_path / 'reports' + tex_dir = reports_dir / 'tex' + tex_dir.mkdir(parents=True) + tex_path = tex_dir / 'report.tex' + pdf_path = reports_dir / 'report.pdf' + tex_path.write_text(r'\documentclass{article}', encoding='utf-8') + pdf_path.write_text('%PDF stale', encoding='utf-8') + warnings = [] + + def fake_run( + command, + *, + cwd, + env, + text, + capture_output, + check, + ): + del cwd, env, text, capture_output, check + return subprocess.CompletedProcess( + command, + 101, + '', + 'panicked at Attempted to create a NULL object', + ) + + monkeypatch.setattr(pdf_compiler, '_find_engines', lambda: [('tectonic', 'tectonic')]) + monkeypatch.setattr(pdf_compiler.subprocess, 'run', fake_run) + monkeypatch.setattr(pdf_compiler.log, 'warning', warnings.append) + + assert pdf_compiler.compile_pdf_report(tex_path) == pdf_path + assert not pdf_path.exists() + assert warnings + assert warnings[0].startswith('PDF skipped:') + + +def test_compile_pdf_report_uses_fallback_after_runtime_failure(tmp_path, monkeypatch): + from easydiffraction.report import pdf_compiler + + reports_dir = tmp_path / 'reports' + tex_dir = reports_dir / 'tex' + tex_dir.mkdir(parents=True) + tex_path = tex_dir / 'report.tex' + pdf_path = reports_dir / 'report.pdf' + tex_path.write_text(r'\documentclass{article}', encoding='utf-8') + + def fake_run( + command, + *, + cwd, + env, + text, + capture_output, + check, + ): + del cwd, env, text, capture_output, check + if command[0] == 'tectonic': + return subprocess.CompletedProcess( + command, + 101, + '', + 'event loop thread panicked', + ) + pdf_path.write_text('%PDF', encoding='utf-8') + return subprocess.CompletedProcess(command, 0, '', '') + + def fail_warning(message): + raise AssertionError(message) + + monkeypatch.setattr( + pdf_compiler, + '_find_engines', + lambda: [('tectonic', 'tectonic'), ('pdflatex', 'pdflatex')], + ) + monkeypatch.setattr(pdf_compiler.subprocess, 'run', fake_run) + monkeypatch.setattr(pdf_compiler.log, 'warning', fail_warning) + + assert pdf_compiler.compile_pdf_report(tex_path) == pdf_path + assert pdf_path.is_file() + + +def test_figure_tex_paths_use_experiment_named_assets(tmp_path): + from easydiffraction.report import pdf_compiler + + tex_dir = tmp_path / 'reports' / 'tex' + data_dir = tex_dir / 'data' + data_dir.mkdir(parents=True) + tex_path = tex_dir / 'report.tex' + figure_path = data_dir / 'hrpt.tex' + bragg_csv_path = data_dir / 'hrpt_lbco.csv' + figure_path.write_text(r'\documentclass{standalone}', encoding='utf-8') + bragg_csv_path.write_text('_refln.two_theta\n1.0\n', encoding='utf-8') + + assert pdf_compiler._figure_tex_paths(tex_path) == [figure_path] diff --git a/tests/unit/easydiffraction/report/test_report.py b/tests/unit/easydiffraction/report/test_report.py deleted file mode 100644 index 8d64e785f..000000000 --- a/tests/unit/easydiffraction/report/test_report.py +++ /dev/null @@ -1,107 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from types import SimpleNamespace - - -def _project(tmp_path): - return SimpleNamespace( - name='report_project', - info=SimpleNamespace(path=tmp_path), - structures={}, - experiments={}, - analysis=SimpleNamespace( - minimizer=SimpleNamespace(type='lmfit'), - fit_result=SimpleNamespace(), - ), - ) - - -def test_report_save_writes_submission_cif(tmp_path): - from easydiffraction.report.report import Report - - report = Report(_project(tmp_path)) - - report_path = report.save() - - assert report_path == tmp_path / 'reports' / 'report_project.cif' - assert report_path.is_file() - assert report_path.read_text(encoding='utf-8').startswith('data_global\n') - - -def test_report_save_check_runs_validation(tmp_path, monkeypatch): - from easydiffraction.report.report import Report - - report = Report(_project(tmp_path)) - checked_paths = [] - - def fake_check(*, path=None): - checked_paths.append(path) - - monkeypatch.setattr(report, 'check', fake_check) - - report_path = report.save(check=True) - - assert checked_paths == [report_path] - - -def test_report_show_report_prints_sections(capsys): - from easydiffraction.report.report import Report - - class Info: - title = 'T' - description = '' - - class Project: - def __init__(self): - self.info = Info() - self.structures = {} # empty mapping to exercise loops safely - self.experiments = {} # empty mapping to exercise loops safely - - class A: - class Minimizer: - type = 'lmfit' - - minimizer = Minimizer() - - class R: - reduced_chi_square = 0.0 - - fit_results = R() - - self.analysis = A() - - report = Report(Project()) - report.show_report() - out = capsys.readouterr().out - # Verify that all top-level sections appear (titles are uppercased by formatter) - assert 'PROJECT INFO' in out - assert 'CRYSTALLOGRAPHIC DATA' in out - assert 'EXPERIMENTS' in out - assert 'FITTING' in out - - -def test_report_help(capsys): - from easydiffraction.report.report import Report - - class P: - pass - - report = Report(P()) - report.help() - out = capsys.readouterr().out - assert 'save()' in out - assert 'check()' in out - assert 'show_report()' in out - assert 'show_project_info()' in out - assert 'show_fitting_details()' in out - - -def test_module_import(): - import easydiffraction.report.report as MUT - - expected_module_name = 'easydiffraction.report.report' - actual_module_name = MUT.__name__ - assert expected_module_name == actual_module_name diff --git a/tests/unit/easydiffraction/report/test_report_details.py b/tests/unit/easydiffraction/report/test_report_details.py deleted file mode 100644 index dd11946eb..000000000 --- a/tests/unit/easydiffraction/report/test_report_details.py +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -# -- Stub classes for test_report_crystallographic_and_experimental --- - - -class _Val: - def __init__(self, v, uncertainty=None): - self.value = v - self.uncertainty = uncertainty - - -class _CellParam: - def __init__(self, name, value, uncertainty=None): - self.name = name - self.value = value - self.uncertainty = uncertainty - - -class _Cell: - def __init__(self): - self.length_a = _CellParam('length_a', 5.4321) - self.length_b = _CellParam('length_b', 5.4321) - self.length_c = _CellParam('length_c', 5.4321) - self.angle_alpha = _CellParam('angle_alpha', 90.0) - self.angle_beta = _CellParam('angle_beta', 90.0) - self.angle_gamma = _CellParam('angle_gamma', 90.0) - - -class _Site: - def __init__(self, label, typ, x, y, z, occ, biso): - self.label = _Val(label) - self.type_symbol = _Val(typ) - self.fract_x = _Val(x) - self.fract_y = _Val(y) - self.fract_z = _Val(z) - self.occupancy = _Val(occ) - self.adp_iso = _Val(biso) - - -class _Model: - def __init__(self): - self.name = 'phaseA' - self.space_group = type('SG', (), {'name_h_m': _Val('P 1')})() - self.cell = _Cell() - self.atom_sites = [_Site('Na1', 'Na', 0.1, 0.2, 0.3, 1.0, 0.5)] - - -class _Instr: - def __init__(self): - self.setup_wavelength = _Val(1.23456) - self.calib_twotheta_offset = _Val(0.12345) - - def _public_attrs(self): - return ['setup_wavelength', 'calib_twotheta_offset'] - - -class _Peak: - def __init__(self): - self.type = 'pseudo-Voigt' - self.broad_gauss_u = _Val(0.1) - self.broad_gauss_v = _Val(0.2) - self.broad_gauss_w = _Val(0.3) - self.broad_lorentz_x = _Val(0.4) - self.broad_lorentz_y = _Val(0.5) - - def _public_attrs(self): - return [ - 'broad_gauss_u', - 'broad_gauss_v', - 'broad_gauss_w', - 'broad_lorentz_x', - 'broad_lorentz_y', - ] - - -class _Expt: - def __init__(self): - self.name = 'exp1' - typ = type( - 'T', - (), - { - 'sample_form': _Val('powder'), - 'radiation_probe': _Val('neutron'), - 'beam_mode': _Val('constant wavelength'), - 'scattering_type': _Val('total'), - }, - ) - self.type = typ() - self.calculator = type( - 'Calculator', - (), - {'type': 'cryspy'}, - )() - self.instrument = _Instr() - self.peak = _Peak() - - def _public_attrs(self): - return ['instrument', 'peak'] - - -class _Info: - title = 'T' - description = '' - - -class _StubProject: - def __init__(self): - self.info = _Info() - self.structures = {'phaseA': _Model()} - self.experiments = {'exp1': _Expt()} - - class A: - class Minimizer: - type = 'lmfit' - - minimizer = Minimizer() - - class R: - reduced_chi_square = 1.23 - - fit_results = R() - - self.analysis = A() - - -# ---------------------------------------------------------------------- - - -def test_report_crystallographic_and_experimental_sections(capsys): - from easydiffraction.report.report import Report - - report = Report(_StubProject()) - # Run both sections separately for targeted assertions - report.show_crystallographic_data() - report.show_experimental_data() - out = capsys.readouterr().out - - # Crystallographic section - assert 'CRYSTALLOGRAPHIC DATA' in out - assert '🧩 phaseA' in out - assert 'Space group' in out - assert 'P 1' in out - assert 'Parameter' in out - assert ' a ' in out - assert ' α ' in out # noqa: RUF001 - assert 'Atom sites' in out - assert 'Na1' in out - assert 'Na' in out - - # Experimental section - assert 'EXPERIMENTS' in out - assert '🔬 exp1' in out - assert 'powder' in out - assert 'neutron' in out - assert 'constant wavelength' in out - assert 'total' in out - assert 'Calculation engine' in out - assert 'cryspy' in out - assert 'Wavelength' in out - assert '1.23456'[:6] in out - assert '2θ offset' in out - assert '0.12345'[:6] in out - assert 'Profile type' in out - assert 'pseudo-Voigt' in out - assert 'Peak broadening (Gaussian)' in out - assert 'Peak broadening (Lorentzian)' in out diff --git a/tests/unit/easydiffraction/report/test_style.py b/tests/unit/easydiffraction/report/test_style.py new file mode 100644 index 000000000..bd4f07ea2 --- /dev/null +++ b/tests/unit/easydiffraction/report/test_style.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_report_style_context_exposes_hex_and_rgb_values(): + from easydiffraction.report.style import report_style_context + + context = report_style_context() + + assert context['axis_hex'] == '#bec7d0' + assert context['axis_rgb'] == '190,199,208' + assert context['grid_hex'] == '#d9dfe4' + assert context['chart_grid_rgb'] == '235,240,248' + assert context['subtitle'] == 'EasyDiffraction Report' + assert 'PT Sans' in context['html_font_family'] diff --git a/tests/unit/easydiffraction/report/test_tex_renderer.py b/tests/unit/easydiffraction/report/test_tex_renderer.py new file mode 100644 index 000000000..41eedca56 --- /dev/null +++ b/tests/unit/easydiffraction/report/test_tex_renderer.py @@ -0,0 +1,461 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import numpy as np + + +def _minimal_context() -> dict[str, object]: + """Return the smallest report context accepted by TeX templates.""" + return { + 'project': { + 'name': 'report_project', + 'title': 'Report Project', + 'description': '', + 'n_phases': 0, + 'n_experiments': 0, + }, + 'publication': { + 'body': { + 'title': '', + 'abstract': '', + 'synopsis': '', + 'keywords': '', + }, + 'authors': [], + 'journal': { + 'name_full': '', + 'year': '', + 'paper_doi': '', + }, + 'contact_author': { + 'name': '', + 'email': '', + }, + }, + 'metadata': { + 'generated_at': '2026-05-26T00:00:00Z', + 'easydiffraction_version': '0.0', + }, + 'refinement': { + 'fit_result': { + 'reduced_chi_square': None, + 'r_factor_all': None, + 'wr_factor_all': None, + }, + 'parameters': { + 'free': 0, + 'total': 0, + }, + 'constraints': 0, + 'rows': [ + {'label': 'Free parameters', 'value': 0, 'numeric': True}, + {'label': 'Total parameters', 'value': 0, 'numeric': True}, + {'label': 'Constraints', 'value': 0, 'numeric': True}, + ], + 'colspec': 'lS[table-format=1.0]', + }, + 'software': { + 'framework': {'name': 'EasyDiffraction', 'version': '0.0'}, + 'calculator': {'name': 'cryspy', 'version': '0.0'}, + 'minimizer': {'name': 'lmfit', 'version': '0.0'}, + }, + 'analysis': { + 'software': { + 'framework': {'name': 'EasyDiffraction', 'version': '0.0'}, + 'calculator': {'name': 'cryspy', 'version': '0.0'}, + 'minimizer': {'name': 'lmfit', 'version': '0.0'}, + }, + 'categories': [], + }, + 'structures': [], + 'experiments': [], + 'figures': {'fit_per_experiment': {}}, + } + + +def _latex_field(label: str, units: str = '') -> dict[str, str]: + """Return one rendered-field metadata mapping.""" + return {'label': label, 'units': units} + + +def _category_row( + name: str, + value: object, + *, + label: str | None = None, + latex_label: str | None = None, + html_label: str | None = None, + units: str = '', + latex_units: str = '', + html_units: str = '', + numeric: bool = False, +) -> dict[str, object]: + """Return one generic category key-value row.""" + return { + 'name': name, + 'label': label or name, + 'latex_label': latex_label or label or name, + 'html_label': html_label or label or name, + 'units': units, + 'latex_units': latex_units or units, + 'html_units': html_units or units, + 'value': value, + 'numeric': numeric, + } + + +def _category_column( + name: str, + *, + label: str | None = None, + latex_label: str | None = None, + html_label: str | None = None, + units: str = '', + latex_units: str = '', + html_units: str = '', + numeric: bool = False, +) -> dict[str, object]: + """Return one generic loop-category column.""" + return { + 'name': name, + 'label': label or name, + 'latex_label': latex_label or label or name, + 'html_label': html_label or label or name, + 'units': units, + 'latex_units': latex_units or units, + 'html_units': html_units or units, + 'numeric': numeric, + } + + +def _category_cell(value: object, *, numeric: bool = False) -> dict[str, object]: + """Return one generic loop-category cell.""" + return {'value': value, 'numeric': numeric} + + +def test_render_tex_report_renders_default_document(): + from easydiffraction.report.tex_renderer import render_tex_report + + context = _minimal_context() + context['project']['description'] = 'Project description.' + tex = render_tex_report(context) + + assert r'\documentclass[11pt]{article}' in tex + assert r'\usepackage[margin=2.5cm]{geometry}' in tex + assert r'\usepackage{fourier}' in tex + assert r'\usepackage{longtable}' in tex + assert r'\usepackage{paratype}' in tex + assert r'\usepackage{titlesec}' in tex + assert r'\sisetup{group-digits=false}' in tex + assert r'\definecolor{rowshade}{RGB}{235,240,248}' in tex + assert r'\definecolor{tableborder}{RGB}{190,199,208}' in tex + assert r'\arrayrulecolor{tableborder}' in tex + assert r'\setlength{\ReportTableColSep}{0.5em}' in tex + assert r'\setlength{\tabcolsep}{\ReportTableColSep}' in tex + assert r'\setlength{\LTleft}{0pt}' in tex + assert ( + r'\newcommand{\rowColorsWithHeader}' + r'{\rowcolors{1}{rowshade}{white}}' + ) in tex + assert ( + r'\newcommand{\rowColorsWithoutHeader}' + r'{\rowcolors{1}{white}{rowshade}}' + ) in tex + assert r'\titlelabel{\thetitle.\enspace}' in tex + assert r'\titleformat*{\section}' in tex + assert r'{\Large EasyDiffraction Report\newline}' in tex + assert r'{\LARGE Report Project\par}' in tex + assert r'\section*{Project Description}' in tex + assert 'Project description.' in tex + assert r'\section{Project Summary}' in tex + assert r'\begin{table}[H]' in tex + assert r'\rowColorsWithoutHeader' in tex + assert r'\rowColorsWithHeader' in tex + assert r'\hline' in tex + assert r'\section{Publication' not in tex + + +def test_render_tex_report_preserves_structure_uncertainty_text(): + from easydiffraction.report.tex_renderer import render_tex_report + + context = _minimal_context() + context['structures'] = [ + { + 'id': 'phase', + 'categories': [ + { + 'kind': 'item', + 'title': 'cell', + 'rows': [ + _category_row( + 'length_a', + '11.985(31)', + latex_label='$a$', + latex_units=r'\AA', + numeric=True, + ), + _category_row( + 'angle_alpha', + '90', + latex_label=r'$\alpha$', + latex_units=r'$^\circ$', + numeric=True, + ), + _category_row('length_b', '2.', numeric=True), + ], + 'has_numeric_values': True, + 'value_column_numeric': True, + 'colspec': 'lS[table-format=2.3(2)]', + }, + { + 'kind': 'loop', + 'title': 'atom_site', + 'scalar_rows': [], + 'scalar_has_numeric_values': False, + 'columns': [ + _category_column('label'), + _category_column('fract_x', numeric=True), + _category_column( + 'adp_iso', + latex_label='$U_{iso}$', + latex_units=r'\AA$^2$', + numeric=True, + ), + ], + 'rows': [ + { + 'cells': [ + _category_cell('Si1'), + _category_cell('11.985(31)', numeric=True), + _category_cell('0.00658(14)', numeric=True), + ], + }, + ], + 'colspec': ('lS[table-format=2.3(2)]S[table-format=1.5(2)]'), + 'table_width': 'full', + }, + { + 'kind': 'loop', + 'title': 'atom_site_aniso', + 'scalar_rows': [], + 'scalar_has_numeric_values': False, + 'columns': [ + _category_column('label'), + _category_column( + 'adp_12', + latex_label='$U_{12}$', + numeric=True, + ), + ], + 'rows': [ + { + 'cells': [ + _category_cell('Si1'), + _category_cell('-0.00048(25)', numeric=True), + ], + }, + ], + 'colspec': 'lS[table-format=+1.5(2)]', + 'table_width': 'half', + }, + ], + } + ] + + tex = render_tex_report(context) + + assert '11.985(31)' in tex + assert '0.00658(14)' in tex + assert '-0.00048(25)' in tex + assert r'$\alpha$ ($\mathrm{deg}$)' in tex + assert r'$U_{iso}$ ($\mathring{\mathrm{A}}^2$)' in tex + assert r'\subsubsection*{atom\_site}' in tex + assert r'\multicolumn{1}{c|}{$U_{12}$}' in tex + assert ( + r'\begin{longtable}{|lS[table-format=2.3(2)]' + r'S[table-format=1.5(2)]|}' + ) in tex + assert r'\resizebox' not in tex + assert r'\hline' in tex + + +def test_render_tex_report_escapes_plain_latex_field_labels(): + from easydiffraction.report.tex_renderer import render_tex_report + + context = _minimal_context() + context['experiments'] = [ + { + 'id': 'hrpt', + 'type': { + 'sample_form': 'powder', + 'radiation_probe': 'neutron', + 'beam_mode': 'constant wavelength', + 'scattering_type': 'bragg', + }, + 'calculator': {'type': 'cryspy'}, + 'diffrn': { + 'ambient_temperature': '', + 'ambient_pressure': '', + }, + 'diffrn_latex': { + 'ambient_temperature': _latex_field('ambient_temperature', 'K'), + 'ambient_pressure': _latex_field('ambient_pressure', 'kPa'), + }, + 'categories': [ + { + 'kind': 'item', + 'title': 'diffrn', + 'rows': [ + _category_row( + 'ambient_temperature', + '', + label='ambient_temperature', + latex_label='ambient_temperature', + units='K', + latex_units='K', + ), + _category_row( + 'ambient_pressure', + '', + label='ambient_pressure', + latex_label='ambient_pressure', + units='kPa', + latex_units='kPa', + ), + ], + 'has_numeric_values': False, + 'value_column_numeric': False, + }, + ], + 'fit_data': { + 'x': { + 'values': [1.0], + 'latex_name': 'time_of_flight', + 'latex_units': 'micro_seconds', + }, + 'axes_labels': ['time_of_flight (micro_seconds)', 'intensity_obs'], + 'series': { + 'meas': {'values': [1.0], 'su': None, 'label': 'Measured'}, + 'calc': {'values': [1.0], 'label': 'Calculated'}, + 'diff': {'values': [0.0], 'label': 'Difference'}, + 'bkg': None, + }, + }, + } + ] + + tex = render_tex_report(context) + + assert r'ambient\_temperature (K)' in tex + assert r'ambient\_pressure (kPa)' in tex + + +def test_save_tex_report_uses_composite_pgfplots_with_error_bars(tmp_path): + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.report.tex_renderer import save_tex_report + + context = _minimal_context() + context['experiments'] = [ + { + 'id': 'hrpt', + 'type': { + 'sample_form': 'powder', + 'radiation_probe': 'neutron', + 'beam_mode': 'constant wavelength', + 'scattering_type': 'bragg', + }, + 'calculator': {'type': 'cryspy'}, + 'diffrn': { + 'ambient_temperature': '', + 'ambient_pressure': '', + }, + 'diffrn_latex': { + 'ambient_temperature': _latex_field('Temperature', 'K'), + 'ambient_pressure': _latex_field('Pressure', 'kPa'), + }, + 'categories': [], + 'fit_data': { + 'x': {'values': [1.0, 2.0]}, + 'axes_labels': ['2θ (deg)', 'Intensity (arb. units)'], + 'series': { + 'meas': {'values': [10.0, 12.0], 'su': [0.2, 0.3]}, + 'calc': {'values': [9.0, 11.0]}, + 'diff': {'values': [1.0, 1.0]}, + 'bkg': {'values': [2.0, 2.5]}, + }, + 'bragg_tick_sets': ( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + ), + }, + } + ] + + tex_path = tmp_path / 'reports' / 'tex' / 'report.tex' + save_tex_report(object(), context, path=tex_path) + figure_tex = (tex_path.parent / 'data' / 'hrpt.tex').read_text( + encoding='utf-8', + ) + csv_header = ( + (tex_path.parent / 'data' / 'hrpt.csv') + .read_text( + encoding='utf-8', + ) + .splitlines()[0] + ) + bragg_csv_header = ( + (tex_path.parent / 'data' / 'hrpt_phase-a.csv').read_text(encoding='utf-8').splitlines()[0] + ) + + assert r'\usepackage{fourier}' in figure_tex + assert r'\usepackage{paratype}' in figure_tex + assert r'\usepgfplotslibrary{groupplots}' in figure_tex + assert r'\begin{groupplot}' in figure_tex + assert 'group size=1 by 3' in figure_tex + assert 'mark layer=like plot' in figure_tex + assert 'mark options={fill=ed_meas, draw=ed_meas, fill opacity=1' in figure_tex + assert 'table[x={_pd_proc.2theta_scan}, y={_pd_meas.intensity_total}' in figure_tex + assert 'table[x={_pd_proc.2theta_scan}, y={_pd_calc.intensity_total}' in figure_tex + assert r'\thisrow{_pd_meas.intensity_total}' in figure_tex + assert 'hrpt_phase-a.csv' in figure_tex + assert 'coordinates {' not in figure_tex + assert 'mark=|' in figure_tex + assert 'ytick={1}' in figure_tex + assert 'yticklabels={{phase-a}}' in figure_tex + assert figure_tex.index('color=ed_meas') < figure_tex.index('color=ed_calc') + assert csv_header == ( + '_pd_proc.2theta_scan,_pd_data.point_id,_pd_proc.d_spacing,' + '_pd_meas.intensity_total,_pd_meas.intensity_total_su,' + '_pd_calc.intensity_total,_pd_calc.intensity_bkg,' + '_pd_data.refinement_status' + ) + assert bragg_csv_header == ( + '_refln.id,_refln.phase_id,_refln.index_h,_refln.index_k,' + '_refln.index_l,_refln.f_calc,_refln.f_squared_calc,_refln.two_theta' + ) + + +def test_save_tex_report_removes_stale_managed_bundle_dirs(tmp_path): + from easydiffraction.report.tex_renderer import save_tex_report + + tex_dir = tmp_path / 'reports' / 'tex' + tex_path = tex_dir / 'report.tex' + for dirname in ('data', 'styles', 'figures'): + stale_path = tex_dir / dirname / 'stale.txt' + stale_path.parent.mkdir(parents=True, exist_ok=True) + stale_path.write_text('stale', encoding='utf-8') + + assert save_tex_report(object(), _minimal_context(), path=tex_path) == tex_path + + assert not (tex_dir / 'data').exists() + assert not (tex_dir / 'figures').exists() + assert not (tex_dir / 'styles').exists() diff --git a/tests/unit/easydiffraction/test___main__.py b/tests/unit/easydiffraction/test___main__.py index ad6c3935c..f02a47317 100644 --- a/tests/unit/easydiffraction/test___main__.py +++ b/tests/unit/easydiffraction/test___main__.py @@ -78,6 +78,20 @@ def test_cli_subcommands_call_utils(monkeypatch): assert logs == ['LIST_DATA', 'DATA_30_projects_False', 'LIST', 'DOWNLOAD_ALL', 'DOWNLOAD_1'] +def test_cli_removed_report_commands_are_unknown(tmp_path): + import easydiffraction.__main__ as main_mod + + project_dir = tmp_path / 'proj' + + save_result = runner.invoke(main_mod.app, ['save', str(project_dir)]) + save_report_result = runner.invoke(main_mod.app, ['save-report', str(project_dir)]) + + assert save_result.exit_code != 0 + assert save_report_result.exit_code != 0 + assert "No such command 'save'" in save_result.output + assert "No such command 'save-report'" in save_report_result.output + + def test_cli_project_first_argument_normalization_supports_global_data_commands(): import easydiffraction.__main__ as main_mod @@ -85,6 +99,16 @@ def test_cli_project_first_argument_normalization_supports_global_data_commands( assert main_mod._normalized_cli_args(['download-data', '30']) == ['download-data', '30'] +def test_cli_project_first_argument_normalization_excludes_removed_report_commands(): + import easydiffraction.__main__ as main_mod + + assert main_mod._normalized_cli_args(['project-dir', 'save']) == ['project-dir', 'save'] + assert main_mod._normalized_cli_args(['project-dir', 'save-report']) == [ + 'project-dir', + 'save-report', + ] + + def test_cli_fit_loads_and_fits(monkeypatch, tmp_path): import easydiffraction.__main__ as main_mod from easydiffraction.project.project import Project diff --git a/tests/unit/easydiffraction/utils/test_matplotlib_config.py b/tests/unit/easydiffraction/utils/test_matplotlib_config.py new file mode 100644 index 000000000..f7e008af4 --- /dev/null +++ b/tests/unit/easydiffraction/utils/test_matplotlib_config.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_path_is_writable_accepts_temporary_directory(tmp_path): + from easydiffraction.utils.matplotlib_config import _path_is_writable + + assert _path_is_writable(tmp_path / 'cache') is True + + +def test_ensure_matplotlib_config_dir_keeps_existing_env(monkeypatch): + from easydiffraction.utils import matplotlib_config + + monkeypatch.setenv('MPLCONFIGDIR', 'configured') + + matplotlib_config.ensure_matplotlib_config_dir() + + assert matplotlib_config.os.environ['MPLCONFIGDIR'] == 'configured' + + +def test_ensure_matplotlib_config_dir_uses_temp_fallback(monkeypatch, tmp_path): + from easydiffraction.utils import matplotlib_config + + calls = [] + + def fake_path_is_writable(path): + calls.append(path) + return len(calls) == 2 + + monkeypatch.delenv('MPLCONFIGDIR', raising=False) + monkeypatch.setattr( + matplotlib_config.pathlib.Path, + 'home', + staticmethod(lambda: tmp_path / 'home'), + ) + monkeypatch.setattr(matplotlib_config.tempfile, 'gettempdir', lambda: str(tmp_path)) + monkeypatch.setattr(matplotlib_config, '_path_is_writable', fake_path_is_writable) + + matplotlib_config.ensure_matplotlib_config_dir() + + assert calls == [ + tmp_path / 'home' / '.matplotlib', + tmp_path / 'easydiffraction-matplotlib', + ] + assert matplotlib_config.os.environ['MPLCONFIGDIR'] == str( + tmp_path / 'easydiffraction-matplotlib' + ) diff --git a/tools/test_structure_check.py b/tools/test_structure_check.py index 9edbd5bf9..fdacc2392 100644 --- a/tools/test_structure_check.py +++ b/tools/test_structure_check.py @@ -53,6 +53,7 @@ EXCLUDED_DIRS: set[str] = { '_vendored', '__pycache__', + 'vendor', } # --------------------------------------------------------------------------- From 2d46651669ce75a1e7d7c007975cb6dcacb87190 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 1 Jun 2026 20:15:19 +0200 Subject: [PATCH 07/12] Initial implementation of the crystal structure visualization (#186) * Add crysview-structure-visualization ADR suggestion * Add crysview-structure-visualization implementation plan * Add crysview viewer and styling enums * Add renderer-neutral structure scene model * Add orthogonalization and ADP eigendecomposition helpers * Add extended element database for radii and colours * Add structure scene builder * Add ASCII structure renderer * Add Viewer facade and factory with ASCII engine * Add style category for structure view styling * Use six scalar descriptors for the view range * Add switchable view category for renderer selection * Wire project.view and project.style with CIF persistence * Add per-structure geom bond-cutoff category * Align single-crystal scatter axes and style in HTML and PDF * Add structure() and show_structure_options() display surface * Add tests for single-crystal scatter range, ticks, and diagonal Cover the new single_crystal_axis_range / single_crystal_tick_step helpers, the data-coordinate diagonal and smaller markers/error bars in the Plotly trace, and the shared x/y range, tick step, and diagonal colour in the report fit-plot context. * Vendor pinned Three.js assets * Add Three.js structure renderer * Embed structure figure in HTML report under html_offline * Document structure view in tutorials and reference * Reach Phase 1 review gate * Resync tutorial notebooks with sources * Default structure view engine to environment-aware auto * Document auto default for structure view engine * Fix single-crystal tick rounding and lighten markers and bars Round the shared tick step to the nearest 1/2/5 power of ten and anchor ticks at 0, so both axes read 0, 500, ..., 2500 (was 750-spaced from the padded minimum). Colour the marker stroke like the fill to drop the ring, and thin the error-bar line and caps. * Test single-crystal tick step rounding and marker stroke Add a regression that the padded heidi range rounds to a 500 step (not 750) and assert the marker stroke colour matches the fill. * Remove unimplemented Structure.show() superseded by display.structure() * Wrap Three.js import map in required imports key * Add import-map regression guard to Phase 2 test plan * Force light theme for structure view in HTML reports * Match structure-view modebar and layout to reference demo * Add atom_scale and per-axis axis lengths to structure scene * Thicken axes, tighten zoom, add PNG download and mixed-site legend * Document atom_scale style option * Rename rendering selectors to rendering_plot/structure/table * Fall back to identity symmetry for unlisted space groups * Update ADRs to renamed rendering_plot/table selectors * Reconcile crysview ADR/plan prose with renamed selectors * Square-root compress atom ball sizes and lower default scale * Halve bond cylinder radius for ball-and-stick balance * Lower default bond increment to 0.25 A for cleaner bonding * Default to a trimetric c-axis-up structure view * Note near-neighbour auto-bonding as a future improvement * Prune bonds to first coordination shell * Reduce default zoom ~20% and left-align legend text * Document first-shell bond prune in ADR, plan, and issues * Write rendering_structure and style sections to project.cif * Add TikZ structure figure to TeX/PDF reports * Document TikZ structure figure in ADR and user guide * Clip TikZ bonds to atom surfaces for correct occlusion * Depth-segment cell edges and overlay axis triad in TikZ figure * Use depth-occluded axes in TeX structure figure * Replace TikZ structure figures with raster PNG * Merge atom_shape and radius_model into atom_view setting * Render true ADP ellipsoids in the raster figure * Demonstrate ADP atom view in ed-14 tutorial * Add colour-scheme selector to the structure view * Render atoms by ADP shape with relative-proportion wedges * Fix anisotropic ADP orientation and reciprocal c-axis * Speed up structure-view zoom; stack the hint in three rows * Add specular highlights to the raster structure figure * Add HTML specular, bond recolour and asymmetric-unit toggle * Drop atomic radius, improve rendering consistency * Fix structure-view distortion at off-axis camera angles * Default view: longest axis horizontal, second-longest up * Rebuild axis arrows at a constant on-screen size * Hide a/b/c axis labels with the axes toggle * Note crysview Phase 2 coverage and superseded prose * Add value-selector-discovery ADR suggestion * Add structure-view-settings implementation plan * Add EnumDescriptor with show_supported listing * Classify enumerated selectors for discovery * Add default and description to value-selector enums * Use EnumDescriptor for experiment_type axes * Use EnumDescriptor for atom_sites adp_type * Use EnumDescriptor for verbosity selector * Use EnumDescriptor for fit_result result_kind * Use EnumDescriptor for fit_parameter_correlations source_kind * Use EnumDescriptor for extinction model selector * Add structure_view content/region category * Add structure_style appearance category * Read structure view and style from new categories * Remove the old style and rendering_structure surfaces * Move model.md structure styling to structure_style * Show occupancy split as a vertical camera-facing pie * Update crysview ADR for the structure-view category split * Reach Phase 1 review gate * Reopen Phase 1 gate pending tutorial migration * Migrate tutorials to structure_style and regenerate notebooks * Reach Phase 1 review gate * Apply pixi run fix auto-fixes * Pin Logger to WARN in rendering_plot invalid-type test * Regenerate drifted tutorial notebooks * Ignore vendored structure renderers * Reorder tutorial rendering and fix jupytext metadata * Reduce report margin to 2cm * Call theme_colors by keyword in Three.js renderer * Refactor and annotate structure display modules for lint * Annotate project view categories and config for lint * Add unit tests for structure display and view categories * Repoint stale tests to renamed and split categories * Reflow docs and regenerate package structure * Update ed-17 tutorial to demonstrate structure_style * Add structure view to remaining tutorials * Frame the raster structure image with a container border * Flatten report headings and widen the structure figure * Frame structure figure within half page height, full width * Document report figure layout in summary-rendering ADR * Refresh structures before display scene building * Clean up implemented ADR and plan docs * Change default atom view to covalent * Document clean report CIF metadata policy * Simplify CIF block naming in reports * Polish structure view dark mode and labels toggle * Refine structure view controls and report axes * Clean report CIF metadata and block references * Keep report datablock names unique * Apply pixi run fix auto-fixes * Add tutorial benchmarks * Finalize Python-CIF category correspondence ADR * Refresh user guide docs for current API * Add documentation CI ADR suggestion * Contain structure view overlays in docs * Format refreshed user guide docs * Render pretty units in fit parameter tables * Sync Plotly and structure themes in docs * Remove redundant line in tutorial docs * Normalize structure toolbar select sizing * Use unified dark plot background * Use transparent plot backgrounds * Clarify structure-view settings ADR coverage * Centralize display theme styling * Add DisplayThemeColors class * Clean up Python-CIF ADR review artifacts * Reset zoom on view reset * Remove implemented plan records * Add responsive resizing for Plotly plots * Remove unused reason column and standardize titles * Add Python-CIF correspondence implementation plan * Promote Python-CIF correspondence ADR * Reach Python-CIF correspondence Phase 1 review gate * Make Plotly backgrounds transparent * Apply pixi run fix auto-fixes * Sync tutorials index.json with index.md and add ed-23 to ed-26 * Add optional width parameter to table rendering * Show plain tutorial title in Jupyter * Specify instrument and data type in tutorials * Consume Pandas table width argument * Add table rendering follow-ups to open issues * Make Plotly modebar background transparent in both themes * Tweak axis frame and grid colors * Size correlation matrix cells to 16 label characters * Merge atoms and asymmetric-unit into a cycle button * Refine theme legend, grid, and frame colors * Sync Plotly theme to annotations, shapes, heatmaps * Sort notebook cell_metadata_filter to avoid diff churn * Use display units in parameter tables * Adjust Three.js structure overlay styling * Keep structure-view secondary axis upright --- .prettierignore | 1 + THIRD_PARTY_LICENSES.md | 12 + .../adrs/accepted/category-owner-sections.md | 10 +- .../crysview-structure-visualization.md | 745 + .../adrs/accepted/crysview-threejs-demo.html | 855 + docs/dev/adrs/accepted/display-ux.md | 15 +- .../adrs/accepted/iucr-cif-tag-alignment.md | 332 +- .../minimizer-category-consolidation.md | 8 +- .../project-facade-and-persistence.md | 21 +- .../accepted/project-summary-rendering.md | 549 +- .../python-cif-category-correspondence.md | 312 +- docs/dev/adrs/accepted/selector-families.md | 10 +- .../switchable-category-owned-selectors.md | 97 +- .../adrs/accepted/value-selector-discovery.md | 223 + docs/dev/adrs/index.md | 77 +- .../suggestions/documentation-ci-build.md | 189 + ...darwin-arm64_py314_tutorial-benchmarks.csv | 26 + docs/dev/issues/closed.md | 15 +- docs/dev/issues/open.md | 119 + docs/dev/package-structure/full.md | 104 +- docs/dev/package-structure/short.md | 47 +- docs/dev/plans/iucr-cif-tag-alignment.md | 633 - docs/dev/plans/project-summary-rendering.md | 1346 - .../python-cif-category-correspondence.md | 104 + docs/docs/assets/javascripts/extra.js | 240 +- docs/docs/assets/stylesheets/extra.css | 76 + docs/docs/index.md | 9 +- docs/docs/quick-reference/index.md | 57 +- docs/docs/tutorials/ed-1.ipynb | 39 +- docs/docs/tutorials/ed-1.py | 4 + docs/docs/tutorials/ed-10.ipynb | 38 +- docs/docs/tutorials/ed-10.py | 6 + docs/docs/tutorials/ed-11.ipynb | 42 +- docs/docs/tutorials/ed-11.py | 10 +- docs/docs/tutorials/ed-12.ipynb | 50 +- docs/docs/tutorials/ed-12.py | 12 +- docs/docs/tutorials/ed-13.ipynb | 334 +- docs/docs/tutorials/ed-13.py | 18 + docs/docs/tutorials/ed-14.ipynb | 132 +- docs/docs/tutorials/ed-14.py | 27 + docs/docs/tutorials/ed-15.ipynb | 64 +- docs/docs/tutorials/ed-15.py | 3 + docs/docs/tutorials/ed-16.ipynb | 48 +- docs/docs/tutorials/ed-16.py | 6 + docs/docs/tutorials/ed-17.ipynb | 146 +- docs/docs/tutorials/ed-17.py | 14 + docs/docs/tutorials/ed-18.ipynb | 28 +- docs/docs/tutorials/ed-18.py | 6 + docs/docs/tutorials/ed-2.ipynb | 74 +- docs/docs/tutorials/ed-2.py | 3 + docs/docs/tutorials/ed-20.ipynb | 69 +- docs/docs/tutorials/ed-20.py | 7 + docs/docs/tutorials/ed-21.ipynb | 111 +- docs/docs/tutorials/ed-21.py | 7 + docs/docs/tutorials/ed-22.ipynb | 97 +- docs/docs/tutorials/ed-22.py | 7 + docs/docs/tutorials/ed-23.ipynb | 42 +- docs/docs/tutorials/ed-23.py | 8 + docs/docs/tutorials/ed-24.ipynb | 40 +- docs/docs/tutorials/ed-24.py | 8 + docs/docs/tutorials/ed-25.ipynb | 109 +- docs/docs/tutorials/ed-25.py | 7 + docs/docs/tutorials/ed-26.ipynb | 54 +- docs/docs/tutorials/ed-26.py | 8 + docs/docs/tutorials/ed-3.ipynb | 531 +- docs/docs/tutorials/ed-3.py | 104 +- docs/docs/tutorials/ed-4.ipynb | 22 +- docs/docs/tutorials/ed-4.py | 6 + docs/docs/tutorials/ed-5.ipynb | 58 +- docs/docs/tutorials/ed-5.py | 8 +- docs/docs/tutorials/ed-6.ipynb | 108 +- docs/docs/tutorials/ed-6.py | 8 +- docs/docs/tutorials/ed-7.ipynb | 134 +- docs/docs/tutorials/ed-7.py | 6 + docs/docs/tutorials/ed-8.ipynb | 36 +- docs/docs/tutorials/ed-8.py | 8 +- docs/docs/tutorials/ed-9.ipynb | 53 +- docs/docs/tutorials/ed-9.py | 7 + docs/docs/tutorials/index.json | 68 +- .../user-guide/analysis-workflow/analysis.md | 197 +- .../analysis-workflow/experiment.md | 45 +- .../user-guide/analysis-workflow/index.md | 4 +- .../user-guide/analysis-workflow/model.md | 115 +- .../user-guide/analysis-workflow/project.md | 58 +- docs/docs/user-guide/concept.md | 19 +- docs/docs/user-guide/data-format.md | 16 +- docs/docs/user-guide/first-steps.md | 38 +- docs/docs/user-guide/index.md | 24 +- docs/docs/user-guide/parameters.md | 36 +- docs/docs/user-guide/parameters/expt_type.md | 35 +- docs/docs/user-guide/parameters/instrument.md | 98 +- docs/docs/user-guide/parameters/peak.md | 71 +- pixi.lock | 1 + pyproject.toml | 8 +- src/easydiffraction/__main__.py | 2 +- src/easydiffraction/analysis/analysis.py | 18 +- .../fit_parameter_correlations/default.py | 13 +- .../analysis/categories/fit_result/base.py | 13 +- src/easydiffraction/analysis/enums.py | 20 + .../analysis/fit_helpers/bayesian.py | 3 +- .../analysis/fit_helpers/reporting.py | 12 +- src/easydiffraction/core/validation.py | 12 +- src/easydiffraction/core/variable.py | 86 + .../crystallography/crystallography.py | 137 +- .../categories/experiment_type/default.py | 60 +- .../categories/extinction/becker_coppens.py | 19 +- .../experiment/categories/peak/base.py | 21 +- .../categories/atom_sites/default.py | 15 +- .../structure/categories/geom/__init__.py | 4 + .../structure/categories/geom/default.py | 72 + .../structure/categories/geom}/factory.py | 6 +- .../datablocks/structure/item/base.py | 27 +- src/easydiffraction/display/plotters/ascii.py | 28 +- .../display/plotters/plotly.py | 773 +- src/easydiffraction/display/plotting.py | 57 +- .../display/structure/__init__.py | 11 + .../display/structure/assets/LICENSES.md | 60 + .../display/structure/assets/__init__.py | 5 + .../display/structure/assets/colors.py | 69 + .../display/structure/assets/elements.py | 253 + .../display/structure/assets/radii.py | 44 + .../display/structure/builder.py | 585 + .../display/structure/enums.py | 97 + .../display/structure/renderers/__init__.py | 10 + .../display/structure/renderers/ascii.py | 313 + .../display/structure/renderers/base.py | 60 + .../display/structure/renderers/raster.py | 883 + .../display/structure/renderers/threejs.py | 225 + .../renderers/vendor/threejs/CSS2DRenderer.js | 215 + .../renderers/vendor/threejs/LICENSES.md | 21 + .../renderers/vendor/threejs/OrbitControls.js | 1417 + .../renderers/vendor/threejs/three.module.js | 53044 ++++++++++++++++ .../display/structure/scene.py | 155 + .../structure/templates/structure.html.j2 | 749 + .../display/structure/viewing.py | 74 + src/easydiffraction/display/tablers/base.py | 10 +- src/easydiffraction/display/tablers/pandas.py | 112 +- src/easydiffraction/display/tablers/rich.py | 6 + src/easydiffraction/display/tables.py | 12 +- src/easydiffraction/display/theme.py | 109 + src/easydiffraction/io/cif/iucr_writer.py | 226 +- src/easydiffraction/io/cif/serialize.py | 34 +- .../project/categories/chart/__init__.py | 8 - .../categories/publication/__init__.py | 15 - .../project/categories/publication/default.py | 657 - .../categories/rendering_plot/__init__.py | 8 + .../{chart => rendering_plot}/default.py | 38 +- .../factory.py | 6 +- .../rendering_structure/__init__.py | 10 + .../categories/rendering_structure/default.py | 98 + .../categories/rendering_structure/factory.py | 17 + .../categories/rendering_table/__init__.py | 8 + .../{table => rendering_table}/default.py | 28 +- .../{table => rendering_table}/factory.py | 4 +- .../project/categories/report/default.py | 2 +- .../categories/structure_style/__init__.py | 8 + .../categories/structure_style/default.py | 105 + .../categories/structure_style/factory.py | 17 + .../categories/structure_view/__init__.py | 8 + .../categories/structure_view/default.py | 191 + .../categories/structure_view/factory.py | 17 + .../project/categories/table/__init__.py | 8 - .../project/categories/verbosity/default.py | 15 +- src/easydiffraction/project/display.py | 204 +- src/easydiffraction/project/project.py | 65 +- src/easydiffraction/project/project_config.py | 46 +- .../project/publication_loader.py | 199 - src/easydiffraction/report/data_context.py | 77 +- src/easydiffraction/report/fit_plot.py | 47 +- src/easydiffraction/report/html_renderer.py | 69 +- src/easydiffraction/report/style.py | 17 +- .../report/templates/html/report.html.j2 | 5 +- .../report/templates/tex/figure_sc.tex.j2 | 5 +- .../report/templates/tex/report.tex.j2 | 16 +- src/easydiffraction/report/tex_renderer.py | 59 + src/easydiffraction/utils/enums.py | 12 + src/easydiffraction/utils/logging.py | 4 +- src/easydiffraction/utils/utils.py | 33 +- .../fitting/test_bayesian_helper_support.py | 59 +- .../analysis/fit_helpers/test_bayesian.py | 47 +- .../analysis/fit_helpers/test_reporting.py | 8 +- .../analysis/test_analysis_access_params.py | 60 + .../analysis/test_analysis_coverage.py | 9 +- .../crystallography/test_crystallography.py | 15 + .../experiment/item/test_base_coverage.py | 7 +- .../structure/categories/geom/test_default.py | 250 + .../structure/categories/geom/test_factory.py | 112 + .../structure/item/test_base_coverage.py | 5 - .../display/plotters/test_ascii.py | 17 + .../display/plotters/test_plotly.py | 161 +- .../display/structure/assets/test_colors.py | 194 + .../display/structure/assets/test_elements.py | 221 + .../display/structure/assets/test_radii.py | 174 + .../display/structure/renderers/test_ascii.py | 547 + .../display/structure/renderers/test_base.py | 199 + .../structure/renderers/test_raster.py | 441 + .../structure/renderers/test_threejs.py | 782 + .../display/structure/test_builder.py | 688 + .../display/structure/test_enums.py | 162 + .../display/structure/test_scene.py | 511 + .../display/structure/test_viewing.py | 296 + .../display/tablers/test_base.py | 3 +- .../display/tablers/test_pandas.py | 11 +- .../easydiffraction/display/test_plotting.py | 50 +- .../easydiffraction/display/test_theme.py | 26 + .../io/cif/test_iucr_writer.py | 59 +- .../project/categories/chart/test_default.py | 104 - .../project/categories/chart/test_factory.py | 23 - .../categories/publication/test_default.py | 20 - .../categories/publication/test_factory.py | 25 - .../categories/rendering_plot/test_default.py | 114 + .../categories/rendering_plot/test_factory.py | 23 + .../rendering_structure/test_default.py | 394 + .../rendering_structure/test_factory.py | 113 + .../test_default.py | 24 +- .../rendering_table/test_factory.py | 23 + .../structure_style/test_default.py | 387 + .../structure_style/test_factory.py | 334 + .../categories/structure_view/test_default.py | 359 + .../categories/structure_view/test_factory.py | 113 + .../project/categories/table/test_factory.py | 23 - .../easydiffraction/project/test_display.py | 126 +- .../easydiffraction/project/test_project.py | 9 +- .../project/test_project_config.py | 58 +- .../project/test_project_load.py | 12 +- .../project/test_publication_loader.py | 57 - .../report/test_data_context.py | 2 +- .../easydiffraction/report/test_fit_plot.py | 27 + .../report/test_html_renderer.py | 7 +- .../unit/easydiffraction/report/test_style.py | 3 +- .../report/test_tex_renderer.py | 53 +- tools/tweak_notebooks.py | 53 +- 232 files changed, 73819 insertions(+), 5936 deletions(-) create mode 100644 docs/dev/adrs/accepted/crysview-structure-visualization.md create mode 100644 docs/dev/adrs/accepted/crysview-threejs-demo.html rename docs/dev/adrs/{suggestions => accepted}/python-cif-category-correspondence.md (61%) create mode 100644 docs/dev/adrs/accepted/value-selector-discovery.md create mode 100644 docs/dev/adrs/suggestions/documentation-ci-build.md create mode 100644 docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv delete mode 100644 docs/dev/plans/iucr-cif-tag-alignment.md delete mode 100644 docs/dev/plans/project-summary-rendering.md create mode 100644 docs/dev/plans/python-cif-category-correspondence.md create mode 100644 src/easydiffraction/datablocks/structure/categories/geom/__init__.py create mode 100644 src/easydiffraction/datablocks/structure/categories/geom/default.py rename src/easydiffraction/{project/categories/chart => datablocks/structure/categories/geom}/factory.py (68%) create mode 100644 src/easydiffraction/display/structure/__init__.py create mode 100644 src/easydiffraction/display/structure/assets/LICENSES.md create mode 100644 src/easydiffraction/display/structure/assets/__init__.py create mode 100644 src/easydiffraction/display/structure/assets/colors.py create mode 100644 src/easydiffraction/display/structure/assets/elements.py create mode 100644 src/easydiffraction/display/structure/assets/radii.py create mode 100644 src/easydiffraction/display/structure/builder.py create mode 100644 src/easydiffraction/display/structure/enums.py create mode 100644 src/easydiffraction/display/structure/renderers/__init__.py create mode 100644 src/easydiffraction/display/structure/renderers/ascii.py create mode 100644 src/easydiffraction/display/structure/renderers/base.py create mode 100644 src/easydiffraction/display/structure/renderers/raster.py create mode 100644 src/easydiffraction/display/structure/renderers/threejs.py create mode 100644 src/easydiffraction/display/structure/renderers/vendor/threejs/CSS2DRenderer.js create mode 100644 src/easydiffraction/display/structure/renderers/vendor/threejs/LICENSES.md create mode 100644 src/easydiffraction/display/structure/renderers/vendor/threejs/OrbitControls.js create mode 100644 src/easydiffraction/display/structure/renderers/vendor/threejs/three.module.js create mode 100644 src/easydiffraction/display/structure/scene.py create mode 100644 src/easydiffraction/display/structure/templates/structure.html.j2 create mode 100644 src/easydiffraction/display/structure/viewing.py create mode 100644 src/easydiffraction/display/theme.py delete mode 100644 src/easydiffraction/project/categories/chart/__init__.py delete mode 100644 src/easydiffraction/project/categories/publication/__init__.py delete mode 100644 src/easydiffraction/project/categories/publication/default.py create mode 100644 src/easydiffraction/project/categories/rendering_plot/__init__.py rename src/easydiffraction/project/categories/{chart => rendering_plot}/default.py (70%) rename src/easydiffraction/project/categories/{publication => rendering_plot}/factory.py (68%) create mode 100644 src/easydiffraction/project/categories/rendering_structure/__init__.py create mode 100644 src/easydiffraction/project/categories/rendering_structure/default.py create mode 100644 src/easydiffraction/project/categories/rendering_structure/factory.py create mode 100644 src/easydiffraction/project/categories/rendering_table/__init__.py rename src/easydiffraction/project/categories/{table => rendering_table}/default.py (76%) rename src/easydiffraction/project/categories/{table => rendering_table}/factory.py (78%) create mode 100644 src/easydiffraction/project/categories/structure_style/__init__.py create mode 100644 src/easydiffraction/project/categories/structure_style/default.py create mode 100644 src/easydiffraction/project/categories/structure_style/factory.py create mode 100644 src/easydiffraction/project/categories/structure_view/__init__.py create mode 100644 src/easydiffraction/project/categories/structure_view/default.py create mode 100644 src/easydiffraction/project/categories/structure_view/factory.py delete mode 100644 src/easydiffraction/project/categories/table/__init__.py delete mode 100644 src/easydiffraction/project/publication_loader.py create mode 100644 tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py create mode 100644 tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py create mode 100644 tests/unit/easydiffraction/display/structure/assets/test_colors.py create mode 100644 tests/unit/easydiffraction/display/structure/assets/test_elements.py create mode 100644 tests/unit/easydiffraction/display/structure/assets/test_radii.py create mode 100644 tests/unit/easydiffraction/display/structure/renderers/test_ascii.py create mode 100644 tests/unit/easydiffraction/display/structure/renderers/test_base.py create mode 100644 tests/unit/easydiffraction/display/structure/renderers/test_raster.py create mode 100644 tests/unit/easydiffraction/display/structure/renderers/test_threejs.py create mode 100644 tests/unit/easydiffraction/display/structure/test_builder.py create mode 100644 tests/unit/easydiffraction/display/structure/test_enums.py create mode 100644 tests/unit/easydiffraction/display/structure/test_scene.py create mode 100644 tests/unit/easydiffraction/display/structure/test_viewing.py create mode 100644 tests/unit/easydiffraction/display/test_theme.py delete mode 100644 tests/unit/easydiffraction/project/categories/chart/test_default.py delete mode 100644 tests/unit/easydiffraction/project/categories/chart/test_factory.py delete mode 100644 tests/unit/easydiffraction/project/categories/publication/test_default.py delete mode 100644 tests/unit/easydiffraction/project/categories/publication/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py rename tests/unit/easydiffraction/project/categories/{table => rendering_table}/test_default.py (64%) create mode 100644 tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/structure_style/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/structure_style/test_factory.py create mode 100644 tests/unit/easydiffraction/project/categories/structure_view/test_default.py create mode 100644 tests/unit/easydiffraction/project/categories/structure_view/test_factory.py delete mode 100644 tests/unit/easydiffraction/project/categories/table/test_factory.py delete mode 100644 tests/unit/easydiffraction/project/test_publication_loader.py diff --git a/.prettierignore b/.prettierignore index 3891a9359..b894e10fb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,6 +26,7 @@ docs/docs/assets/ node_modules # Vendored snapshots +src/easydiffraction/display/structure/renderers/vendor/ src/easydiffraction/report/templates/html/vendor/ src/easydiffraction/report/templates/tex/styles/ src/easydiffraction/utils/_vendored/jupyter_dark_detect/ diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ceb7fcf87..96e7c70ec 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -11,3 +11,15 @@ The vendored report LaTeX style files are documented in The vendored report HTML assets are documented in `src/easydiffraction/report/templates/html/vendor/LICENSES.md`. + +## Structure-View Three.js + +The vendored Three.js assets for the crysview structure view (MIT) are +documented in +`src/easydiffraction/display/structure/renderers/vendor/threejs/LICENSES.md`. + +## Structure-View Element Data + +The bundled per-element radii and colour palettes (with per-source +provenance) are documented in +`src/easydiffraction/display/structure/assets/LICENSES.md`. diff --git a/docs/dev/adrs/accepted/category-owner-sections.md b/docs/dev/adrs/accepted/category-owner-sections.md index a5cf27b39..0091b4c59 100644 --- a/docs/dev/adrs/accepted/category-owner-sections.md +++ b/docs/dev/adrs/accepted/category-owner-sections.md @@ -85,13 +85,13 @@ Its current children are: The public API stays flat and user-facing: - `project.info` -- `project.chart` -- `project.table` +- `project.rendering_plot` +- `project.rendering_table` Saved `project.cif` remains a section file without a `data_` header. It -serializes the `_project.*` metadata category plus the `_chart.*` and -`_table.*` configuration categories without pretending that the project -config is a real datablock. +serializes the `_project.*` metadata category plus the +`_rendering_plot.*` and `_rendering_table.*` configuration categories +without pretending that the project config is a real datablock. ### 4. CIF serialization is split by responsibility diff --git a/docs/dev/adrs/accepted/crysview-structure-visualization.md b/docs/dev/adrs/accepted/crysview-structure-visualization.md new file mode 100644 index 000000000..c6dd4c154 --- /dev/null +++ b/docs/dev/adrs/accepted/crysview-structure-visualization.md @@ -0,0 +1,745 @@ +# ADR: crysview Structure Visualization + +## Status + +Accepted. + +**Date:** 2026-05-31 + +**Implementation note:** A later implementation pass split +structure-view configuration into `rendering_structure`, +`structure_view`, and `structure_style`. This ADR reflects that final +surface; no separate `structure-view-settings` ADR exists. + +## Context + +EasyDiffraction refines crystal structures but offers no interactive 3D +view of them. The accepted [Display UX Facade](display-ux.md) ADR +defines `project.display` for 1D pattern charts and for parameter, fit, +and posterior tables and plots, but nothing spatial: there is no way to +look at the atoms, the unit cell, or the anisotropic displacement +parameters a refinement is adjusting. + +A working prototype establishes the target experience and the data it +needs. It lives at +[`crysview-threejs-demo.html`](crysview-threejs-demo.html) and +demonstrates, against a non-orthogonal unit cell: + +- atoms as spheres with element radius and colour; +- anisotropic ADP ellipsoids (semi-axis lengths plus orientation); +- mixed-occupancy atoms drawn as occupancy wedges (a sphere split by + site occupancy); +- two-colour bonds split at their midpoint; +- magnetic-moment arrows; +- an a/b/c axis triad drawn longer than the cell edges; +- a Plotly-style modebar: perspective/parallel projection toggle, + view-along-a/b/c buttons, a home/reset button, and per-feature + visibility toggles for cell, axes, atoms, bonds, moments, and labels; +- a shrink-wrapped legend, hover tooltips, and persistent atom labels; +- orbit / zoom / pan controls and both perspective and orthographic + cameras, with parallel projection as the default. + +The prototype's input comment already separates the concerns: a +crystallography layer performs symmetry expansion, fractional → +Cartesian conversion, ADP eigendecomposition, and element-radius lookup; +a visualization layer chooses sizes, colours, bonds, and occupancy +splitting; and the renderer only consumes prepared geometry. + +Relevant facts about the current codebase: + +- The structure model lives under + `src/easydiffraction/datablocks/structure/categories/`: `cell`, + `atom_sites` (fractional coordinates, occupancy, isotropic ADP), + `atom_site_aniso` (anisotropic ADP), and `space_group`. +- The 1D charting subsystem already uses a switchable-engine pattern. + `project.rendering_plot.type` selects a plotter engine implemented + under `src/easydiffraction/display/plotters/` (`ascii.py`, + `plotly.py`), and `project.rendering_table.type` selects a tabler. + These follow the switchable-category ADRs, with CIF tags + `_rendering_plot.type` and `_rendering_table.type`. +- `easycrystallography` is **not** a dependency today and is not + imported anywhere in `src/`. Any layering that places a separate + visualization package between `easycrystallography` and + `easydiffraction` is therefore a future direction, not the current + state. + +The audience is scientists, often non-programmers, working mostly in +Jupyter notebooks and the planned GUI. Discoverability, clear names, and +safe defaults take priority over developer ergonomics. + +## Decision + +This ADR records the accepted first version of the structure viewer. +Points that earlier reviews left open are settled in the sections below; +the historical open questions are retained only to document the final +choices. + +### 1. Build a renderer-neutral structure scene + +crysview converts a crystal structure into a prepared, renderer-neutral +**structure scene**: a flat collection of typed primitives expressed in +Cartesian space and carrying no rendering-library types. The primitive +set matches the prototype: + +- atom spheres (centre, radius, colour); +- occupancy wedges for mixed-occupancy sites; +- ADP ellipsoids (semi-axes scaled to the configured probability, plus + orientation); +- bonds (two endpoints, split colour); +- magnetic-moment arrows; +- unit-cell edges; +- the a/b/c axis triad; +- text labels. + +All crystallographic computation — symmetry expansion over the +configured cell range (section 3), fractional → Cartesian conversion, +ADP eigendecomposition, radii and colours from the selected model and +colour scheme, bond detection, and occupancy splitting — happens while +building the scene, upstream of any renderer. This is the contract the +prototype already assumes. + +### 2. Draw the scene with thin, pluggable renderers + +Renderers consume the scene and draw it; they hold no crystallographic +logic. Renderer choice mirrors `project.rendering_plot.type`: + +- an ASCII renderer for terminal, CLI, and headless contexts; +- a Three.js renderer for notebooks (embedded HTML/JS) and standalone + HTML; +- a raster renderer that emits a static, z-buffered PNG image for the + TeX/PDF report — a trimetric projection of the same scene with a + per-pixel depth buffer, so hidden-surface removal is exact (atoms, + bonds, cell edges, and axes all occlude correctly). It is **not** a + user-selectable engine (it is invoked by the report, like `pgfplots` + is for the fit plot). The z-buffer rasterisation is plain numpy; it + uses `Pillow` to draw the a/b/c axis labels and the element legend and + to encode the PNG; +- a Qt Quick 3D renderer for the GUI is planned. + +ASCII and Three.js are the initial interactive engines, shipping +together exactly as the `ascii` and `plotly` chart engines do; the +raster renderer serves the TeX/PDF report, and Qt Quick 3D follows for +the GUI. + +A switchable engine selector is added on the project owner, parallel to +`project.rendering_plot` / `project.rendering_table`. It is named +`rendering_structure`: + +```python +project.rendering_structure.type = 'auto' # default: 'threejs' in Jupyter, 'ascii' in a terminal +project.rendering_structure.show_supported() +``` + +with CIF tag `_rendering_structure.type`. The name parallels +`rendering_plot` / `rendering_table`, and follows the category-owned +selector contract: `project.rendering_structure` is a read-only +attribute on the owner; `project.rendering_structure.type` is the +writable selector; `project.rendering_structure.show_supported()` lists +engines. Switching `type` calls the owner's private +`_swap_rendering_structure` hook, which rebinds the active renderer — +the same Family B rebinding the plot engine selector uses — so no public +`rendering_structure_type` setter or +`show_supported_rendering_structure_types()` is added. The default is +`auto`, which resolves at draw time to `threejs` in a Jupyter notebook +and `ascii` in a terminal — exactly as `_rendering_plot.type` / +`_rendering_table.type` resolve their environment defaults. + +### 3. Add a `structure()` entry point on the display facade + +Add `project.display.structure(struct_name=...)`, parallel to the +existing `project.display.pattern(expt_name=...)`. It renders one +structure with the active `view` engine — interactive 3D in a notebook, +a schematic projection in the terminal. In the Three.js engine, feature +visibility, projection, and view-along presets are interactive through +the modebar with sensible defaults (parallel projection; cell, axes, +atoms, and bonds visible, plus moments where the data exists; labels +off). In a notebook it embeds an interactive view (an IPython HTML +representation); like the HTML report it can also write a standalone +HTML file to a path. The exact return and save signature is left to the +implementation plan. + +Content selection mirrors `pattern(include=...)` rather than inventing a +new vocabulary: + +```python +project.display.structure(struct_name='lbco') +project.display.structure(struct_name='lbco', include='auto') +project.display.structure( + struct_name='lbco', + include=('atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'), +) +``` + +`include='auto'` shows what the structure state supports (cell, axes, +atoms, bonds, and moments where moment data exists; labels off by +default). The option vocabulary is `auto`, `atoms`, `bonds`, `cell`, +`axes`, `moments`, and `labels`. ADP ellipsoids and mixed-occupancy +splits are not separate keywords: they are drawn automatically as part +of `atoms` (anisotropic ADP gives an ellipsoid, isotropic a sphere; a +mixed site is split), so the data decides. The interactive modebar +toggles the same features after the initial view is drawn, so `include` +sets the starting state and the modebar refines it. + +A companion `project.display.show_structure_options(struct_name=...)` +mirrors the existing `show_pattern_options(expt_name=...)`: it lists +each `include=` option with whether the active engine and the current +structure state support it, and the reason when they do not — for +example `moments` is unavailable until the structure model carries +moment fields, and the `ascii` engine reports the features only the 3D +engines draw. This gives the structure view the same per-option +discoverability the pattern view already offers. + +The view also has a spatial extent: which symmetry-equivalent atoms the +scene contains. The scene builder takes the unique (asymmetric-unit) +atoms, applies the space-group symmetry, and keeps every generated copy +whose fractional coordinates fall within a per-axis range, **borders +included**. The default range is `[0, 1]` on each of a, b, and c, so a +full unit cell is drawn with the atoms on the 0 and 1 faces, edges, and +corners all present (a corner site therefore appears at all eight +corners). The range is user-settable per axis, validated so each minimum +is below its maximum, and need not be integer — `[0, 2]` along a draws +two cells, `[-0.2, 1.2]` adds a margin. Like the other settings it is +persisted and overridable per call: + +```python +# Persisted per-axis bounds — six scalar settings, like the cell +# parameters (defaults 0 and 1 on each axis = the full cell, borders +# included): +project.structure_view.range_a_max = 2 # two cells along a +project.structure_view.range_c_min, project.structure_view.range_c_max = -0.2, 1.2 # margin on c + +# A convenience tuple overrides the persisted range for one call only: +project.display.structure( + struct_name='lbco', + range=((0, 2), (0, 1), (0, 1)), +) +``` + +Symmetry expansion can map several operations onto one point — a site on +a special position, or the shared 0-and-1 faces the default range keeps +— so the scene builder applies a **scene-atom identity rule** as it +collects copies. Two generated atoms are the same scene atom when they +come from the same atom-site row _and_ their fractional coordinates +coincide within a small tolerance (`1e-4` in fractional units); the +builder keeps one and drops the rest. The tolerance is far below any +cell fraction, so a copy at 0 and its border-included copy at 1 are +distinct positions and both survive — special-position overlaps collapse +without discarding the intentional boundary translations. The atom-site +row participates in the key, so two different rows that happen to share +a position are not merged here; that case is occupancy grouping, handled +next. + +When two or more atom-site rows resolve to the same position (within +that same tolerance), the scene builder groups them into one +**occupancy-wedge sphere** rather than overdrawing coincident spheres: +each row contributes a wedge whose angular share is proportional to its +occupancy. Coincident position is the only grouping signal the model +offers — atom sites carry an occupancy but no disorder-group or +occupancy-group field — so it is the documented version-1 criterion. +When the grouped occupancies sum below one, the remainder is drawn as a +vacancy wedge so the empty fraction is visible (a lone site with +occupancy below one is the one-row case); when they meet or exceed one, +the shares are normalized to their sum and no vacancy wedge is drawn. +The builder invents no occupancies — it shows exactly what the rows +carry. + +Because expansion happens in the scene builder (section 1), the 3D +engines draw this expanded set in full. The `ascii` engine is the +reduced-fidelity sibling (section 7): it always renders the single +default cell and reports a wider view range as a 3D-only capability +through `show_structure_options()`, the same way it announces the other +features only the 3D engines draw. + +### 4. Start internal, design for later extraction + +Implement crysview first as an internal subpackage that mirrors +`display/plotters/` (for example +`src/easydiffraction/display/structure/` with renderers under a +`renderers/` subpackage). Keep the scene model free of +easydiffraction-domain imports so it can later be extracted into a +standalone `crysview` package and, eventually, consume +`easycrystallography`. Do **not** add `easycrystallography` as a +dependency now. + +### 5. Pin and deliver Three.js deliberately + +The prototype loads a pinned Three.js (`three@0.160.0`) plus +`OrbitControls` and `CSS2DRenderer` through a CDN importmap. Production +ships the pinned Three.js bundled with the package so the notebook and +standalone-HTML views are autonomous — they render with no network, +which is what a CDN-blocked or sandboxed context (where the demo renders +blank) needs. This mirrors how the existing report path already embeds +its JavaScript when asked: `report/html_renderer.py` exposes an +`offline` flag that sets `include_plotlyjs=True` (embed) versus `'cdn'`, +gated in the template by `html_offline`. + +The HTML report's structure view follows the same rule: it honours the +report's `html_offline` flag, embedding the Three.js assets when offline +and otherwise linking them, so a structure figure behaves like the +existing Plotly figures in a report. + +### 6. Source styling from standard models and colour schemes + +Atom radii and colours are not typed in per element. They follow from +**standard, user-selected models** that every structure viewer +recognises, looked up automatically from each atom's element (and its +charge where the model needs it): + +- a **radius model** turns an element into a sphere radius — van der + Waals, ionic (Shannon; the site charge where a model carries one, + otherwise a documented per-element default, see below), or covalent; +- a **colour scheme** is a named element-colour palette — the Jmol/CPK + scheme, the VESTA scheme, and similar well-known sets. + +A scientist picks one model and one scheme instead of editing dozens of +per-element rows, which keeps the view consistent and reproducible: + +```python +project.structure_style.atom_view = 'covalent' # vdw | covalent | ionic | adp +project.structure_style.color_scheme = 'jmol' # jmol | vesta +project.structure_style.atom_view.show_supported() +project.structure_style.color_scheme.show_supported() +``` + +How an atom is sized and shaped is a single **display-style switch**, +`atom_view`, because the standard radius models and the ADP probability +surface are alternative depictions and a view shows one of them at a +time: + +- `'vdw'`, `'covalent'`, `'ionic'` draw every atom as a **radius-model + sphere** for the named standard radius table; displacement parameters + do not affect size. This is the familiar ball-and-stick depiction and + works for any structure, with or without ADP. +- `'adp'` draws each atom as its **ADP probability surface** — a sphere + for an atom with only isotropic ADP, an ellipsoid (semi-axes and + orientation from the ADP tensor) for an anisotropic one. Atoms that + carry no ADP fall back to a covalent-radius sphere. This is the + thermal-ellipsoid (ORTEP) depiction crystallographers use to inspect + the displacement parameters a refinement adjusts. + +The default is `'covalent'`, because it gives every structure a stable +charge-free ball view. Users can switch to `'adp'` when they want to +inspect displacement surfaces. + +> **Amendment — `atom_view` merge.** An earlier design split this into +> two settings: `atom_shape` (`ball`/`ortep`) and `radius_model` +> (`vdw`/`covalent`/`ionic`/`atomic`). They were merged into the single +> `atom_view` selector because `radius_model` was meaningful only in +> ball mode, so the two-field form carried four degenerate +> `ortep`×radius-model combinations. The flat list removes the dead +> states and matches how VESTA/Mercury present the choice. The +> `atomic`/empirical option was then dropped, leaving +> `{vdw, covalent, ionic, adp}`: its radii are within a few percent of +> `covalent` for most elements (and identical for some), so after +> ball-size compression it was visually indistinguishable and added a +> redundant choice. The atomic radii remain in the element database, +> unused by the public selector. The `adp` view still uses covalent +> radii for the ball fallback and for mixed-occupancy sites. CIF field: +> `_structure_style.atom_view`. + +In `'adp'` the surfaces are drawn at one **probability level**, +`adp_probability`, a fraction in the open interval (0, 1) — not a +percentage — validated on assignment. It defaults to `0.5` (the ORTEP +and journal 50% convention) and is freely changeable (for example +`0.95`). It has no effect in the radius-model views. + +Which bonds the view draws is **not** a styling choice — it is a +geometric property of the structure, and it follows the **standard +cif_core `_geom` auto-bonding model**, not the display `atom_view`. A +bond is drawn between two sites when their distance `d` satisfies +`_geom.min_bond_distance_cutoff ≤ d ≤ r_bond(i) + r_bond(j) + _geom.bond_distance_incr`, +where the per-type bonding radius `r_bond` is `_atom_type.radius_bond` +when the structure carries it, otherwise the element's covalent radius +from the bundled database. Matches are then pruned to the first +coordination shell — a contact is kept only when it is within `1.3×` the +nearer atom's nearest-neighbour distance — so the large covalent radii +of ionic A-site cations do not bond to every surrounding anion (a +heuristic stop-gap; see open issue #108 for the full near-neighbour +approach). These two cutoffs live on the **structure** and persist in +the structure's own CIF (see section 8), not in +`project.structure_style`. The `atom_view` radius models (vdw / covalent +/ ionic) change only the rendered sphere _size_ — they never decide +which bonds appear; bond detection is governed solely by the `_geom` +cutoffs and the per-type bonding radius. Version 1 draws bonds computed +on the fly from this rule while the scene is built and persists no bond +table. The full computed bond and angle geometry — the standard +`_geom_bond` and `_geom_angle` loops, with distances, angles, symmetry +codes, and standard uncertainties — is a separate, related feature that +reuses the same symmetry-expansion and distance math (see Deferred +Work). + +`atom_view` and `color_scheme` are finite, closed value sets, so each is +a `(str, Enum)` validated on assignment per the +[Enum-Backed Closed Value Sets](enum-backed-closed-values.md) ADR, and +each selector lists its accepted values through descriptor-level +`show_supported()` — for example +`project.structure_style.atom_view.show_supported()`. `structure_style` +is a plain category, not a switchable one: it has no factory-swapped +`type`, only these validated value settings. + +The defaults are the **`covalent`** atom view and the **Jmol/CPK** +colour scheme, so the view looks right with no configuration. Covalent +radii are preferred because they are backed by complete, well-documented +per-element data and need no oxidation state: today's atom-site model +carries only an element symbol — no charge, oxidation-state, or +coordination field — so a model that depends on charge cannot be +resolved per site yet. + +The radii and colours come from a **bundled element database** — a +package asset, like the colour palettes, not a per-project value, so it +is not CIF-serialized; the project CIF records only which model and +scheme are selected. The database carries, per element, the van der +Waals, covalent, ionic (a representative Shannon radius at a documented +default oxidation state and coordination), and atomic/empirical radii, +plus the Jmol/CPK and VESTA colour palettes, each value carrying a +documented provenance. The ionic entries let `atom_view = 'ionic'` work +today against the documented default oxidation state; when a future +atom-site charge field exists the ionic model will prefer the site's +charge. An element with no entry for the selected radius model falls +back to its covalent radius, and `show_structure_options()` reports the +substitution instead of failing. Version 1 adds no per-element overrides +on top of the chosen model and scheme. + +All of this is CIF-persisted, so a reopened project renders identically. +The decision is that styling is **an atom-shape mode plus model, scheme, +and probability-level selection**, not a per-element table; the exact +CIF tag names and serialization shape are pinned in the implementation +plan (Open Questions, resolved). + +The view also adapts to the host's **colour theme**. Like the Plotly +chart engine — which selects the `plotly_dark` or `plotly_white` +template from the detected theme — the structure view reuses the +project's existing dark/light detection (`is_dark()` in +`utils/_vendored`) and switches the scene background and the label, +axis, and edge colours to match, so a notebook in dark mode gets a dark +canvas. Element colours still come from the selected colour scheme +regardless of theme; only the surrounding canvas and annotations follow +it. The theme is auto-detected, not a persisted styling value. + +### 7. Terminal view (ASCII engine) + +The `ascii` engine renders in the terminal, mirroring the existing +`ascii` chart plotter: it builds a character grid and prints it, with no +GUI or JavaScript. Like that chart engine — which openly announces the +features only Plotly can draw — it is a deliberately reduced-fidelity +sibling of the 3D engines: one schematic projection, one unit cell, and +no bonds, labels, ADP ellipsoids, or moment arrows. When an `include=` +request asks for one of those features, the engine announces it is +available with the 3D engines and skips it, just as the ascii chart +engine does for Plotly-only features. A view range wider than the +default single cell is treated the same way: the terminal view always +draws one cell and announces that multi-cell and margin ranges are +honored only by the 3D engines, so its schematic stays uncluttered and +the single parallelogram never disagrees with the atoms it frames. + +Like the other engines it consumes the same renderer-neutral scene +(section 1): it projects the scene's Cartesian atom centres and +unit-cell edges onto a plane and draws a schematic 2D view. The longest +in-plane cell axis runs horizontally, the shortest vertically, and the +remaining (middle-length) axis is the viewing direction. + +The cell is drawn as a schematic parallelogram. Its two side edges are +rasterized with the asciichartpy glyph set (`│ ╭ ╮ ╯ ╰ ─`), and the +staircase slope encodes the in-plane angle: near 90° gives long `│` runs +with few corners (a rectangle at exactly 90°), while a larger deviation +from 90° introduces more `╭╯` steps (mirrored to `╰╮` for the opposite +lean). The view is schematic — lengths and angles are approximate, just +enough to convey the cell — so non-orthogonal cells render the same way +as orthogonal ones, with the slant shown rather than dropped. + +Atoms are drawn as coloured Unicode circles: colour by element from the +selected colour scheme (the scene colour from section 6, mapped to the +nearest terminal colour) and size by a small radius-bucketed glyph ramp +(for example `· • ● ⬤`). Each axis arrow points to its letter: the +vertical axis is the letter stacked over an up-arrow above the cell (`c` +then `↑`), and the horizontal axis is a right-arrow pointing to the +letter at the end of the bottom-border line, after a short gap (`→ a`). +Each axis arrow and its letter are tinted with that axis's colour — the +same a/b/c colours the scene gives the 3D engines, mapped to the nearest +terminal colour and reset afterwards, just as the existing ASCII chart +legend colours its entries. A legend maps each glyph to its element +name, and both the legend glyph and its element label are tinted with +that element's colour-scheme colour (mapped to the nearest terminal +colour and reset afterwards), so the atoms in the cell, the legend, and +the axis letters all share the one selected colour scheme. The mocks +below are monochrome; a real terminal shows these colours. + +An orthorhombic cell viewed down b, with vertical side edges: + +``` + c + ↑ + ╭─────────────────────────╮ + │ ● ● │ + │ ⬤ │ + │ • ● │ + ╰─────────────────────────╯ → a + + Legend: ● La ● Ba ⬤ Co • O +``` + +A monoclinic cell viewed down b, with slanted side edges: + +``` + c + ↑ + ╭─────────────────────────╮ + ╭╯ ● ● ╭╯ + ╭╯ ⬤ ╭╯ + ╭╯ • ● ╭╯ + ╰─────────────────────────╯ → a + + Legend: ● La ● Ba ⬤ Co • O +``` + +A small gap-free line helper provides the edge rasterization: it +generalizes the asciichartpy connector (fill vertical runs with `│`, cap +bends with corner glyphs) so it can be walked row-major for the +near-vertical edges that the column-major chart code cannot express. + +### 8. Configuring what is shown and how + +The view has three configuration axes — _which engine_ draws it, _what_ +is shown, and _how_ it is styled. They are three flat project +categories, all persisted to CIF: + +- `project.rendering_structure` selects the renderer engine only. +- `project.structure_view` stores durable content and region settings. +- `project.structure_style` stores durable appearance settings. + +```python +# How: renderer engine +project.rendering_structure.type = 'auto' # default: 'threejs' in Jupyter, 'ascii' in a terminal +project.rendering_structure.show_supported() + +# How: standard styling models, not per-element values (visual only) +project.structure_style.atom_view = 'covalent' # vdw | covalent | ionic | adp +project.structure_style.color_scheme = 'jmol' # jmol | vesta +project.structure_style.adp_probability = 0.5 # ADP probability level (0, 1) +project.structure_style.atom_scale = 0.3 # overall atom scale (0, 1] +project.structure_style.atom_view.show_supported() +project.structure_style.color_scheme.show_supported() + +# Which bonds exist: a per-structure geometric property, not styling. +# Standard cif_core _geom auto-bonding (r_bond defaults to covalent radius): +# bond iff min_cutoff <= d <= r_bond(i) + r_bond(j) + incr. +structure = project.structures['lbco'] +structure.geom.min_bond_distance_cutoff = 0.0 # default 0.0 Å +structure.geom.bond_distance_incr = 0.25 # default 0.25 Å (documented, tunable) + +# What (per call): content for one view, overriding the initial defaults +project.display.structure(struct_name='lbco') # 'auto' +project.display.structure( + struct_name='lbco', + include=('atoms', 'bonds', 'cell', 'axes'), +) + +# Initial view state (persisted): what is shown when the view opens. The +# Three.js modebar stays active, so the user can still toggle each +# feature live afterwards. show_moments stays inert until the structure +# model carries moment fields (see Deferred Work). +project.structure_view.show_labels = False +project.structure_view.show_moments = True + +# What region (persisted): six per-axis fractional bounds (defaults 0 and +# 1 = full cell, borders included), mirroring the six scalar cell +# parameters. +project.structure_view.range_a_min = 0 +project.structure_view.range_a_max = 1 # range_b_min/max and range_c_min/max likewise +``` + +The persisted equivalent in the project CIF: + +``` +# In the project CIF (project-level view + style): +_rendering_structure.type auto + +_structure_view.show_labels false +_structure_view.show_moments true +_structure_view.range_a_min 0 +_structure_view.range_a_max 1 +_structure_view.range_b_min 0 +_structure_view.range_b_max 1 +_structure_view.range_c_min 0 +_structure_view.range_c_max 1 + +_structure_style.atom_view covalent +_structure_style.color_scheme jmol +_structure_style.adp_probability 0.5 +_structure_style.atom_scale 0.3 + +# In the structure (sample) CIF, beside _cell / _atom_site (per-structure): +_geom.min_bond_distance_cutoff 0.0 +_geom.bond_distance_incr 0.25 +``` + +The `_rendering_structure.type` tag follows `_rendering_plot.type` / +`_rendering_table.type` from the Display UX Facade ADR, including their +`auto` environment-default convention (resolved to `threejs` in Jupyter, +`ascii` in a terminal); `_geom.min_bond_distance_cutoff` and +`_geom.bond_distance_incr` are the **standard cif_core** bond-cutoff +tags (`_atom_type.radius_bond` is the standard per-type bonding radius, +used when present). The `_structure_view.*`, `_structure_style.*`, and +`_rendering_structure.type` tags are project-internal app settings. + +Initial visibility resolves in a fixed order, so a reopened project and +a per-call request behave predictably: + +1. **An explicit `include=(...)` tuple wins outright.** The view opens + showing exactly those features; persisted `_structure_view.show_*` + flags are ignored for that call. So `include=('atoms',)` shows only + atoms even when `show_labels=True` is persisted. +2. **`include='auto'`** — the default, and what a bare `structure()` + call uses — resolves each feature in turn from: data availability + first (a feature with no data is off, such as moments without moment + fields), then the persisted `_structure_view.show_*` flag where one + exists, then the built-in default otherwise. Version 1 persists flags + only for the two features whose default a scientist most often flips + — `show_labels` (off) and `show_moments` (on where data exists); + atoms, bonds, cell, and axes follow their built-in 'auto' defaults + and are set per call through an explicit `include=` tuple. So + `show_labels=True` with `include='auto'` opens with labels on. +3. **Unsupported options are skipped and announced, never errored.** + Whether it arrived through an explicit tuple or 'auto', a feature the + engine cannot draw (any 3D-only feature under `ascii`) or the data + does not support (moments without fields) is reported by + `show_structure_options()` and at draw time. +4. **Live modebar changes apply on top of that initial state and are + runtime-only.** Toggling a feature in the Three.js modebar never + rewrites the persisted `_structure_view.show_*` flags or the + `include=` set, so reopening the project restores the resolved + initial state rather than the last live toggle. + +## Consequences + +- `project.display` gains a spatial view (`structure()`) that + complements the 1D `pattern()` view and reuses the `include=` + vocabulary. +- `project.display` also gains `show_structure_options()`, parallel to + `show_pattern_options()`, so the supported content for a given + structure and engine is discoverable with reasons. +- Keeping crystallography in the scene builder and out of renderers lets + several front-ends (Three.js now, Qt Quick 3D later) share one model. +- A switchable `rendering_structure` category + (`project.rendering_structure.type`, CIF `_rendering_structure.type`) + selects only the engine, per the switchable-category and + category-owner ADRs. Plain `structure_view` and `structure_style` + sibling categories hold content/region and appearance settings. +- The `ascii` and `threejs` engines ship together, mirroring the chart + engines: `ascii` needs no JavaScript and renders a schematic view in + the terminal, CLI, and headless contexts, while `threejs` covers + notebooks and HTML. +- Content selection (`include=`) and a small set of visibility flags + become persisted _initial-view_ settings, so a project reopens looking + the same; the interactive engines still let the user toggle features + live. +- The scene's spatial extent is configurable: a per-axis fractional + range (default `[0, 1]`, borders included) decides which + symmetry-equivalent atoms are generated, so a single cell, an added + margin, or several cells need no new primitives. The 3D engines draw + the expanded set; the `ascii` engine draws the single default cell and + reports wider ranges as a 3D-only capability. +- The styling category lets scientists choose a standard atom view + (`vdw`, `covalent`, `ionic`, or `adp`), a colour scheme, an ADP + probability level, and an overall atom scale — not hand-edit + per-element rows — all CIF-persisted, with defaults that work + unconfigured. The radii and colours come from a bundled element + database (covalent, vdW, ionic, and atomic radii; Jmol/CPK and VESTA + palettes) shipped as a package asset. +- Bond generation is a per-structure geometric property, not styling: it + uses the standard cif_core `_geom` auto-bonding cutoffs + (`_geom.min_bond_distance_cutoff`, `_geom.bond_distance_incr`) plus a + per-type bonding radius (`_atom_type.radius_bond`, defaulting to the + covalent radius), all on the structure and persisted in the structure + CIF — not in `project.structure_style`, and independent of the display + `atom_view`. Version 1 draws bonds on the fly and persists no bond + table; the full computed `_geom_bond` / `_geom_angle` tables are + deferred to a separate feature. +- The structure view auto-detects the host's dark/light theme (reusing + the project's existing `is_dark()` detection) and adapts its + background and annotation colours, mirroring how the Plotly chart + engine switches templates; element colours still come from the + selected colour scheme. +- The scene builder must expose occupancy splitting, anisotropic ADP, + and magnetic moments. Where the current structure model lacks a field + (magnetic moments are not in `atom_sites`/`atom_site_aniso` today), + that feature stays gated until the model provides the data. +- A pinned Three.js version becomes a bundled package asset to keep up + to date, and the HTML report embeds it under `html_offline`. +- Tutorials and public API docs gain a structure-view example. + +## Alternatives Considered + +- **Reuse the 1D chart engines (Plotly) for 3D.** Rejected: Plotly's 3D + primitives do not express ADP ellipsoids, occupancy wedges, or + crystallographic camera/axis controls cleanly. +- **Put rendering directly in easydiffraction with no scene + abstraction.** Rejected: it couples crystallography to one rendering + library and blocks the planned GUI renderer. +- **Start as a standalone `crysview` package and adopt + `easycrystallography` now.** Rejected for the first step as a + premature dependency and repo split before the design is proven; + retained as the strategic direction. +- **Server-rendered static images instead of an interactive scene.** + Rejected: it loses the interactivity (rotate, toggle, view-along) + scientists expect when inspecting a structure. + +## Open Questions + +All items below are now **resolved** so the implementation plan can be +executed autonomously; the plan records the verified data sources and +the final names. + +- **CIF tag spelling — resolved (see the §8 _Updated_ note for the final + split).** Project CIF: `_structure_style.atom_view` / + `_structure_style.color_scheme` / `_structure_style.adp_probability` / + `_structure_style.atom_scale`; `_structure_view.show_labels` / + `_structure_view.show_moments` / + `_structure_view.range_{a,b,c}_{min,max}`; and + `_rendering_structure.type` (engine only). These are project-internal + app/settings tags (`_rendering_structure.type` follows the Display-UX + `_rendering_plot.type` / `_rendering_table.type` precedent); the radii + and colours are a bundled element-database asset, not CIF-serialized. +- **Per-structure bond-cutoff category — resolved (standard + `_geom.*`).** A single-record `structure.geom` category holding the + cif_core cutoffs `_geom.min_bond_distance_cutoff` (default `0.0` Å) + and `_geom.bond_distance_incr` (default `0.25` Å, documented and + tunable), in the structure datablock. A bond is drawn when + `min_bond_distance_cutoff ≤ d ≤ r_bond(i) + r_bond(j) + bond_distance_incr`, + with `r_bond` = `_atom_type.radius_bond` when present, else the + covalent radius. These are the **standard** cif_core tags (review-4 + finding 1): `_geom.min_bond_distance_cutoff` (dic 13084), + `_geom.bond_distance_incr` (dic 13044), `_atom_type.radius_bond` (dic + 25419); the earlier project-internal `_bonds.*` proposal was dropped. + The computed `_geom_bond.*` / `_geom_angle.*` loops remain reserved + for the deferred geometry tables. +- **ASCII rendering details — resolved.** A 4-bucket radius glyph ramp + (`· • ● ⬤`) and the 8/16-colour ANSI mapping the existing ascii chart + legend already uses. +- **Per-axis range boundary completion — resolved.** Version 1 draws + only atoms inside the range (borders included) and bonds only between + in-scene atoms — no out-of-range partner atoms or edge-coordination + completion. The range is persisted as six scalar tags + `_structure_view.range_{a,b,c}_{min,max}` (one number each, defaults 0 + and 1), mirroring the six scalar cell parameters; a per-call `range=` + tuple on `structure()` overrides them for one call. + +## Deferred Work + +- The computed bond and angle geometry tables — the standard + `_geom_bond` and `_geom_angle` loops (atom-pair/triplet labels, + distances, angles, site-symmetry codes, standard uncertainties, + `publ_flag`) — as a separate, related feature. It reuses crysview's + symmetry-expansion and distance math and the same per-structure + `_geom` cutoffs (extended with the angle/contact increments cif_core + already defines). Version 1 draws bonds on the fly from the `_geom` + bond cutoffs and persists no geometry table. +- The Qt Quick 3D renderer for the GUI. +- Magnetic-moment fields on the structure model (a separate + magnetic-structure effort); the scene's moment-arrow primitive stays + gated until they exist. +- Extraction of a standalone `crysview` package and the + `easycrystallography` layering. +- Advanced depictions beyond atoms, bonds, and ADP surfaces, such as + coordination polyhedra. Symmetry expansion and multiple-cell views are + in scope through the per-axis range (section 3). diff --git a/docs/dev/adrs/accepted/crysview-threejs-demo.html b/docs/dev/adrs/accepted/crysview-threejs-demo.html new file mode 100644 index 000000000..82148ff35 --- /dev/null +++ b/docs/dev/adrs/accepted/crysview-threejs-demo.html @@ -0,0 +1,855 @@ + + + + + + crysview — Three.js structure prototype + + + + +
+
+ +
+
+ + + + + +
+
+
+ + + + + + +
+
+ +
+
+ A/B (50/50) +
+
C
+
D
+
+ +
+
drag = rotate
+
wheel = zoom
+
right-drag = pan
+
+ + + + diff --git a/docs/dev/adrs/accepted/display-ux.md b/docs/dev/adrs/accepted/display-ux.md index 379bdb8c8..40ee2c0d1 100644 --- a/docs/dev/adrs/accepted/display-ux.md +++ b/docs/dev/adrs/accepted/display-ux.md @@ -43,21 +43,22 @@ defaults. Use `project.display` as the user-facing facade for display actions. Move serialized renderer settings out of that facade and into separate -project categories named `project.chart` and `project.table`. +project categories named `project.rendering_plot` and +`project.rendering_table`. Renderer settings: ```python -project.chart.type = 'plotly' -project.table.type = 'pandas' -project.chart.show_supported() -project.table.show_supported() +project.rendering_plot.type = 'plotly' +project.rendering_table.type = 'pandas' +project.rendering_plot.show_supported() +project.rendering_table.show_supported() ``` CIF names: -- `_chart.type` -- `_table.type` +- `_rendering_plot.type` +- `_rendering_table.type` No legacy loader is required for `_display.plotter_type` or `_display.tabler_type`. The project is in beta, so this cleanup may diff --git a/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md index 3072bc81c..d36193346 100644 --- a/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md +++ b/docs/dev/adrs/accepted/iucr-cif-tag-alignment.md @@ -5,12 +5,12 @@ Reframes the earlier "IUCr CIF Tag Alignment for Fit Outputs" suggestion (2026-05-24, PR #181) into a tiered policy. The default saved CIFs stay -optimised for day-to-day UX; a separate IUCr export path produces -journal-submission CIFs on demand. Amends parts of +optimised for day-to-day UX; a separate IUCr export path produces clean +report CIFs on demand. Amends parts of [`analysis-cif-fit-state.md`](analysis-cif-fit-state.md) and [`minimizer-input-output-split.md`](minimizer-input-output-split.md); runs alongside the -[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +[`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) suggestion (Python-side correspondence). Grounded in: @@ -40,11 +40,11 @@ commonly produced by tooling that has not yet caught up with the current DDLm spec. The dictionaries are the source of truth. The submission-specific publication dictionary (`cif_publ.dic`) is not -consulted directly — the publication-block items are all present in -`cif_core.dic` under `_journal.*`, `_journal_coeditor.*`, -`_journal_date.*`, `_publ_author.*`, `_publ_contact_author.*`, -`_publ_body.*`, `_publ_manuscript.*`, `_audit.*`, and -`_chemical_formula.*`. +consulted directly. The v1 report CIF deliberately avoids empty journal +and author template fields; the retained global metadata comes from +`cif_core.dic` items such as `_audit.*`, `_computing.*`, and +`_chemical_formula.*`. Deferred journal/publication tags are listed in +[`project-summary-rendering.md`](project-summary-rendering.md) §5.1. ## Context @@ -59,11 +59,12 @@ and item names: without a custom mapping layer. - **Day-to-day UX.** Users switch between Python and direct CIF editing in a CLI. Some IUCr-canonical structures are awkward for hand editing - — submission templates require multi-datablock layouts with - `data_global` publication metadata, embedded `_publ_*` placeholder - fields, and TOF calibration as a coefficient loop indexed by integer - `power`. Parametric profile shape (Caglioti, FCJ, TOF sigma/gamma) has - no IUCr counterpart at all. + — submission templates often include multi-datablock layouts with + `data_global` metadata and TOF calibration as a coefficient loop + indexed by integer `power`. The v1 report keeps the structural pieces + and omits empty `_publ_*` / `_journal_*` placeholders. Parametric + profile shape (Caglioti, FCJ, TOF sigma/gamma) has no IUCr counterpart + at all. A blanket "align with IUCr everywhere" policy pays a UX cost the project does not need to absorb for files that are not submission targets. A @@ -74,7 +75,7 @@ Two earlier ADRs already touch this surface: - [`loop-category-key-identity.md`](loop-category-key-identity.md) pins loop-key naming on COMCIFS conventions. -- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) catalogues Python-vs-CIF category mismatches and chooses which side should bend. @@ -86,8 +87,8 @@ In scope: - A tiered category-and-item-name policy for the default save, split by domain (structure / analysis / experiment). -- A new IUCr export path that produces a single journal-submission CIF - on demand, separate from the default save. +- A new IUCr export path that produces a single clean report CIF on + demand, separate from the default save. - ADP write-side single-tag emission and casing alignment in the structure tier. - Loop-tag style policy: dotted DDLm form universally on write, both @@ -96,21 +97,21 @@ In scope: on `CifHandler`, category-level `IucrCategoryTransformer` for structural reshapings). - Multi-datablock layout in the IUCr export, including the `data_global` - publication-metadata block. + audit, software, and chemistry metadata block. Out of scope: - Python attribute renames. This ADR changes CIF emission only. Cross-reference - [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) for Python-side decisions. - Adding new CIF categories the project does not currently track (`_chemical.*`, `_publ.*`, `_journal.*`) **for the default save**. The - IUCr export emits the publication-metadata categories per §2.3a with - `?` placeholders where the project has no source data. + IUCr export derives `_chemical_formula.*` for the report only and does + not emit `_publ_*` / `_journal_*` placeholders in v1. - imgCIF (`cif_img.dic`); no raw image persistence path exists. -- Project-level singleton categories `_info.*`, `_chart.*`, `_table.*`, - `_verbosity.*` — out of scope here; see +- Project-level singleton categories `_info.*`, `_rendering_plot.*`, + `_rendering_table.*`, `_verbosity.*` — out of scope here; see `python-cif-category-correspondence`. ## Design Philosophy: Tiered Default Save + Separate IUCr Export @@ -159,44 +160,44 @@ Project CIF categories audited against `cif_core.dic` v3.4.0 and category changes in the default save; the "IUCr export" column shows the dotted DDLm tag emitted by the IUCr CIF report writer. -| Category (current) | IUCr dictionary | Default-save tier | IUCr export (dotted DDLm) | -| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `_cell.*` | core | Structure — unchanged | `_cell.length_a`, `_cell.angle_alpha`, etc. | -| `_atom_site.*` (most fields) | core | Structure — unchanged | `_atom_site.label`, `_atom_site.fract_x`, … | -| `_atom_site.adp_type` | core (`_atom_site.ADP_type`) | Structure — casing fix | `_atom_site.ADP_type` (uppercase ADP per dictionary). | -| `_atom_site.wyckoff_letter` | core (`_atom_site.Wyckoff_symbol`) | Structure — rename | `_atom_site.Wyckoff_symbol` (uppercase W, "symbol" not "letter"). | -| `_atom_site.B_iso_or_equiv` / `U_iso_or_equiv` | core | Structure — single-tag emit | `_atom_site.B_iso_or_equiv` xor `_atom_site.U_iso_or_equiv` per row, based on `_atom_site.ADP_type`. | -| `_atom_site_aniso.B_*` / `U_*` | core | Structure — single-tag emit | `_atom_site_aniso.B_*` xor `_atom_site_aniso.U_*` per row. | -| `_space_group.name_h_m` | core (`_space_group.name_H-M_alt`) | Structure — casing fix | `_space_group.name_H-M_alt`. | -| `_space_group.it_coordinate_system_code` | core (`_space_group.IT_coordinate_system_code`) | Structure — casing fix | `_space_group.IT_coordinate_system_code`. | -| symmetry operations | core (`_space_group_symop.*`) | (not emitted today) | `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop alongside the H-M name. | -| `_diffrn.ambient_temperature`, `ambient_pressure` | core | Experiment — unchanged | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`. | -| `_diffrn.ambient_magnetic_field`, `ambient_electric_field` | none | Experiment — unchanged | `_easydiffraction_diffrn.ambient_magnetic_field`, `…electric_field` (project extension). | -| `_refln.*` | core | (no default save under refln) | `_refln.*` reflections loop (column set differs by domain — see §2.3). | -| `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*`, `_pd_data.*` | pdCIF | Experiment — unchanged | `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*` profile-data loop (see §2.3). | -| `_pd_background.*` | pdCIF | Experiment — unchanged | `_pd_background.*`. | -| `_pd_phase_block.*` | pdCIF | Experiment — unchanged | `_pd_phase_block.*`. | -| `_sc_crystal_block.*` | community (no IUCr counterpart) | Experiment — unchanged | `_easydiffraction_sc_crystal_block.*` in IUCr export. | -| `_instr.wavelength` | core (`_diffrn_radiation_wavelength.value`) | Experiment — unchanged | `_diffrn_radiation_wavelength.{id, value, wt}` — single-row category for monochromatic; loop only for multi-λ. | -| `_instr.2theta_offset` | pdCIF (`_pd_calib.2theta_offset`) | Experiment — unchanged | `_pd_calib.2theta_offset`. | -| `_instr.2theta_bank`, `d_to_tof_*` | pdCIF (`_pd_calib_d_to_tof.*` loop) | Experiment — unchanged | Four-row loop `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`. | -| `_peak.*` (parametric profile shape) | none (pdCIF has no shape parameters) | Experiment — unchanged | `_easydiffraction_peak.*` + `_pd_proc_ls.profile_function` free-text descriptor. | -| `_extinction.*` | core (`_refine_ls.extinction_*` items) | Experiment — unchanged | `_easydiffraction_extinction.*` + dual emit `_refine_ls.extinction_{method,coef,expression}`. | -| `_excluded_region.*` | pdCIF (`_pd_proc.info_excluded_regions` free-text) | Experiment — unchanged | `_easydiffraction_excluded_region.*` + `_pd_proc.info_excluded_regions` free-text rendering. | -| `_expt_type.*` | none | Experiment — unchanged | `_easydiffraction_experiment_type.*`. | -| `_calculator.type`, `_minimizer.type` | none | Analysis — unchanged | Selection fields remain settings only; identity is read from `analysis.software` for `_easydiffraction_software.{framework, calculator, minimizer}` and `_computing.structure_refinement`. | -| `_software.*` | none | Analysis — new provenance category | Source for `_easydiffraction_software.{framework, calculator, minimizer}`, `_easydiffraction_software.fit_datetime`, and `_computing.structure_refinement` in `data_global`. | -| `_minimizer.*` settings (tolerances, max_iter, …) | none | Analysis — unchanged | `_easydiffraction_minimizer.*` (settings only, separate from the identification triple). | -| `_fitting_mode.type`, `_background.type` | none | Analysis / Experiment — unchanged | `_easydiffraction_fitting_mode.type`, `_easydiffraction_background.type` selectors. | -| `_fit_result.reduced_chi_square`, `n_data_points`, `n_parameters` | core (`_refine_ls.*`) and pdCIF (`_pd_proc_ls.*`) | Analysis — unchanged (topology-neutral) | Shape-shifting per topology: see §1.2 and §3 transformers. | -| `_fit_result.*` (R-factors, counts, profile/background function) | core / pdCIF | Analysis — new fields under `_fit_result.*` | IUCr export remaps to per-topology `_refine_ls.*` / `_pd_proc_ls.*`; item names already match dictionary casing (§1.2). | -| `_fit_result.*` (Bayesian diagnostics, success, message, fitting_time, iterations, result_kind) | none | Analysis — unchanged | `_easydiffraction_fit_result.*`. | -| `_fit_parameter`, `_fit_parameter_correlation` | none / partial | Analysis — unchanged | `_easydiffraction_fit_parameter*` (no IUCr counterpart for per-parameter posterior). | -| `_alias`, `_constraint` | none | Analysis — unchanged | `_easydiffraction_alias*`, `_easydiffraction_constraint*`. | -| `_joint_fit`, `_sequential_fit*` | none | Analysis — unchanged | `_easydiffraction_joint_fit*`, `_easydiffraction_sequential_fit*`. | -| reflection-set aggregates | core (`_reflns.*`) | Analysis — new fields | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` (e.g. `'I>3\s(I)'`). | -| publication metadata | core (`_journal.*`, `_publ_author.*`, `_publ_contact_author.*`, `_audit.*`) | (not emitted today) | Emitted in `data_global` block per §2.3a with `?` placeholders. | -| analysis-stack identification | core (`_computing.structure_refinement`) | Analysis — `_software.*` persisted | `_easydiffraction_software.{framework, calculator, minimizer}` triple + `_easydiffraction_software.fit_datetime` + `_computing.structure_refinement` derived from `analysis.software`. | +| Category (current) | IUCr dictionary | Default-save tier | IUCr export (dotted DDLm) | +| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `_cell.*` | core | Structure — unchanged | `_cell.length_a`, `_cell.angle_alpha`, etc. | +| `_atom_site.*` (most fields) | core | Structure — unchanged | `_atom_site.label`, `_atom_site.fract_x`, … | +| `_atom_site.adp_type` | core (`_atom_site.ADP_type`) | Structure — casing fix | `_atom_site.ADP_type` (uppercase ADP per dictionary). | +| `_atom_site.wyckoff_letter` | core (`_atom_site.Wyckoff_symbol`) | Structure — rename | `_atom_site.Wyckoff_symbol` (uppercase W, "symbol" not "letter"). | +| `_atom_site.B_iso_or_equiv` / `U_iso_or_equiv` | core | Structure — single-tag emit | `_atom_site.B_iso_or_equiv` xor `_atom_site.U_iso_or_equiv` per row, based on `_atom_site.ADP_type`. | +| `_atom_site_aniso.B_*` / `U_*` | core | Structure — single-tag emit | `_atom_site_aniso.B_*` xor `_atom_site_aniso.U_*` per row. | +| `_space_group.name_h_m` | core (`_space_group.name_H-M_alt`) | Structure — casing fix | `_space_group.name_H-M_alt`. | +| `_space_group.it_coordinate_system_code` | core (`_space_group.IT_coordinate_system_code`) | Structure — casing fix | `_space_group.IT_coordinate_system_code`. | +| symmetry operations | core (`_space_group_symop.*`) | (not emitted today) | `_space_group_symop.id` + `_space_group_symop.operation_xyz` loop alongside the H-M name. | +| `_diffrn.ambient_temperature`, `ambient_pressure` | core | Experiment — unchanged | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`. | +| `_diffrn.ambient_magnetic_field`, `ambient_electric_field` | none | Experiment — unchanged | `_easydiffraction_diffrn.ambient_magnetic_field`, `…electric_field` (project extension). | +| `_refln.*` | core | (no default save under refln) | `_refln.*` reflections loop (column set differs by domain — see §2.3). | +| `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*`, `_pd_data.*` | pdCIF | Experiment — unchanged | `_pd_meas.*`, `_pd_proc.*`, `_pd_calc.*` profile-data loop (see §2.3). | +| `_pd_background.*` | pdCIF | Experiment — unchanged | `_pd_background.*`. | +| `_pd_phase_block.*` | pdCIF | Experiment — unchanged | `_pd_phase_block.*`. | +| `_sc_crystal_block.*` | community (no IUCr counterpart) | Experiment — unchanged | `_easydiffraction_sc_crystal_block.*` in IUCr export. | +| `_instr.wavelength` | core (`_diffrn_radiation_wavelength.value`) | Experiment — unchanged | `_diffrn_radiation_wavelength.{id, value, wt}` — single-row category for monochromatic; loop only for multi-λ. | +| `_instr.2theta_offset` | pdCIF (`_pd_calib.2theta_offset`) | Experiment — unchanged | `_pd_calib.2theta_offset`. | +| `_instr.2theta_bank`, `d_to_tof_*` | pdCIF (`_pd_calib_d_to_tof.*` loop) | Experiment — unchanged | Four-row loop `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}`. | +| `_peak.*` (parametric profile shape) | none (pdCIF has no shape parameters) | Experiment — unchanged | `_easydiffraction_peak.*` + `_pd_proc_ls.profile_function` free-text descriptor. | +| `_extinction.*` | core (`_refine_ls.extinction_*` items) | Experiment — unchanged | `_easydiffraction_extinction.*` + dual emit `_refine_ls.extinction_{method,coef,expression}`. | +| `_excluded_region.*` | pdCIF (`_pd_proc.info_excluded_regions` free-text) | Experiment — unchanged | `_easydiffraction_excluded_region.*` + `_pd_proc.info_excluded_regions` free-text rendering. | +| `_expt_type.*` | none | Experiment — unchanged | `_easydiffraction_experiment_type.*`. | +| `_calculator.type`, `_minimizer.type` | none | Analysis — unchanged | Selection fields remain settings only; identity is read from `analysis.software` for `_easydiffraction_software.{framework, calculator, minimizer}` and `_computing.structure_refinement`. | +| `_software.*` | none | Analysis — new provenance category | Source for `_easydiffraction_software.{framework, calculator, minimizer}`, `_easydiffraction_software.fit_datetime`, and `_computing.structure_refinement` in `data_global`. | +| `_minimizer.*` settings (tolerances, max_iter, …) | none | Analysis — unchanged | `_easydiffraction_minimizer.*` (settings only, separate from the identification triple). | +| `_fitting_mode.type`, `_background.type` | none | Analysis / Experiment — unchanged | `_easydiffraction_fitting_mode.type`, `_easydiffraction_background.type` selectors. | +| `_fit_result.reduced_chi_square`, `n_data_points`, `n_parameters` | core (`_refine_ls.*`) and pdCIF (`_pd_proc_ls.*`) | Analysis — unchanged (topology-neutral) | Shape-shifting per topology: see §1.2 and §3 transformers. | +| `_fit_result.*` (R-factors, counts, profile/background function) | core / pdCIF | Analysis — new fields under `_fit_result.*` | IUCr export remaps to per-topology `_refine_ls.*` / `_pd_proc_ls.*`; item names already match dictionary casing (§1.2). | +| `_fit_result.*` (Bayesian diagnostics, success, message, fitting_time, iterations, result_kind) | none | Analysis — unchanged | `_easydiffraction_fit_result.*`. | +| `_fit_parameter`, `_fit_parameter_correlation` | none / partial | Analysis — unchanged | `_easydiffraction_fit_parameter*` (no IUCr counterpart for per-parameter posterior). | +| `_alias`, `_constraint` | none | Analysis — unchanged | `_easydiffraction_alias*`, `_easydiffraction_constraint*`. | +| `_joint_fit`, `_sequential_fit*` | none | Analysis — unchanged | `_easydiffraction_joint_fit*`, `_easydiffraction_sequential_fit*`. | +| reflection-set aggregates | core (`_reflns.*`) | Analysis — new fields | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` (e.g. `'I>3\s(I)'`). | +| report metadata | core (`_audit.*`, `_computing.*`, `_chemical_formula.*`) and project extension (`_easydiffraction_software.*`) | Analysis / derived report state | Emitted in `data_global` block per §2.3a. Empty `_journal.*`, `_publ_*`, and `_pd_meas.info_author_*` placeholders are excluded by the clean-report policy in `project-summary-rendering.md` §5. | +| analysis-stack identification | core (`_computing.structure_refinement`) | Analysis — `_software.*` persisted | `_easydiffraction_software.{framework, calculator, minimizer}` triple + `_easydiffraction_software.fit_datetime` + `_computing.structure_refinement` derived from `analysis.software`. | ## Decision @@ -296,7 +297,7 @@ Specifically: - Parametric profile shape (`_peak.*` Caglioti / Lorentzian / FCJ / TOF coefficients) stays under `_peak.*`. -### 2. Reports — IUCr submission CIF +### 2. Reports — IUCr-aligned report CIF #### 2.1 API @@ -360,7 +361,7 @@ quartz_sc/ # plus Bayesian / non-IUCr fields) # _easydiffraction_minimizer.* (settings) reports/ - quartz_sc.cif # data_global — _journal.*, _publ_*, _audit.*, + quartz_sc.cif # data_global — _audit.*, # _easydiffraction_software.{framework, # calculator, minimizer}, # _computing.structure_refinement, @@ -386,21 +387,21 @@ mgo_rietveld/ analysis/ analysis.cif reports/ - mgo_rietveld.cif # data_global — publication metadata, software, _chemical_formula - # data_mgo_rietveld_overall — _pd_proc_ls.prof_R_factor, - # .prof_wR_factor, - # .prof_wR_expected, - # .profile_function, - # .background_function, - # _refine_ls.number_parameters, - # _pd_block_id cross-refs - # data_mgo_rietveld_phase_0 — MgO structure - # data_mgo_rietveld_pwd_0 — _pd_meas.* profile loop - # (_2theta_scan, intensity_total, - # _pd_calc.intensity_total, - # _pd_proc.intensity_bkg_calc, - # _pd_proc_ls.weight), - # _refln.* powder reflections loop + mgo_rietveld.cif # data_global — audit, software, _chemical_formula + # data_overall — _pd_proc_ls.prof_R_factor, + # .prof_wR_factor, + # .prof_wR_expected, + # .profile_function, + # .background_function, + # _refine_ls.number_parameters, + # _pd_block_id cross-refs + # data_mgo — MgO structure + # data_npd — _pd_meas.* profile loop + # (_2theta_scan, intensity_total, + # _pd_calc.intensity_total, + # _pd_proc.intensity_bkg_calc, + # _pd_proc_ls.weight), + # _refln.* powder reflections loop ``` **Example C — Joint Rietveld, multi-experiment (neutron + X-ray).** @@ -416,12 +417,12 @@ co2sio4/ analysis/ analysis.cif # _joint_fit weights reports/ - co2sio4.cif # data_global — publication metadata - # data_co2sio4_overall — combined refinement stats - # data_co2sio4_phase_0 — Co2SiO4 structure - # data_co2sio4_pwd_0 — NPD pattern, + co2sio4.cif # data_global — audit, software, chemistry + # data_overall — combined refinement stats + # data_co2sio4 — Co2SiO4 structure + # data_npd_300K — NPD pattern, # _pd_block_diffractogram_id='npd_300K' - # data_co2sio4_pwd_1 — XRD pattern, + # data_xrd_300K — XRD pattern, # _pd_block_diffractogram_id='xrd_300K' ``` @@ -440,28 +441,27 @@ co2sio4_t_series/ analysis/ analysis.cif # _sequential_fit configuration reports/ - co2sio4_t_series.cif # data_global — publication metadata - # data_co2sio4_t_series_overall - # data_co2sio4_t_series_phase_0 - # data_co2sio4_t_series_pwd_0 — TOF 5K, + co2sio4_t_series.cif # data_global — audit, software, chemistry + # data_overall + # data_co2sio4 + # data_tof_5K — TOF 5K, # _pd_meas.time_of_flight, # _pd_calib_d_to_tof loop - # data_co2sio4_t_series_pwd_1 — TOF 100K - # data_co2sio4_t_series_pwd_2 — TOF 165K - # data_co2sio4_t_series_pwd_3 — TOF 200K + # data_tof_100K — TOF 100K + # data_tof_165K — TOF 165K + # data_tof_200K — TOF 200K ``` #### 2.3 Multi-datablock layout inside the export file -**Every export file starts with a `data_global` block carrying -publication metadata** (§2.3a). Subsequent blocks depend on analysis -topology. Block content uses dotted DDLm form throughout. The +**Every export file starts with a `data_global` block carrying audit, +software, and chemistry metadata** (§2.3a). Subsequent blocks depend on +analysis topology. Block content uses dotted DDLm form throughout. The single-block-name rule is uniform across topologies; topology-specific GSAS-II-style suffix conventions seen in some example files (e.g. `data__publ`, `data__overall`) are folded into -`data_global` for the publication header and `data__overall` -for refinement metadata, leaving no ambiguity about where the -journal-required publication items live. +`data_global` for global metadata and `data_overall` for refinement +metadata, leaving no ambiguity about block roles. - **Single-crystal, single structure (single experiment).** `data_global` + `data_` (or `data_I` if no name is set). @@ -473,33 +473,46 @@ journal-required publication items live. `bp5014.cif`: `data_global + data_300K + data_55K + data_2point5K`. - **Powder Rietveld (single or multi-experiment, single or - multi-phase).** GSAS-II-style block split, with the publication block - renamed to `data_global` per the invariant above: - - `data_global` (publication metadata, per §2.3a), - - `data__overall` (refinement-level metadata — Rietveld - R-factors, profile/background function descriptors, parameter - counts), - - `data__phase_N` (one per phase — structural data per + multi-phase).** GSAS-II-style block split, with the global metadata + block named `data_global` per the invariant above: + - `data_global` (audit, software, and chemistry metadata per §2.3a), + - `data_overall` (refinement-level metadata — Rietveld R-factors, + profile/background function descriptors, parameter counts), + - `data_` (one per phase — structural data per `_pd_phase_block.id`), - - `data__pwd_N` (one per diffraction pattern — measurement + - `data_` (one per diffraction pattern — measurement metadata, profile data loop, reflections loop). This deviates from the `data__publ` GSAS-II convention seen in `hb8206.cif`; the deviation buys a uniform rule across single-crystal and powder exports and matches the single-crystal corpus (`bal5004`, etc.) which uses `data_global` universally. + `data_overall` is deliberately unprefixed because the generated report + CIF is already scoped to one project. `global` means file/report + metadata; `overall` means combined refinement summary. Phase and + diffractogram blocks use the existing structure and experiment names + after CIF block-code normalization; if two normalized names collide, + append a short numeric suffix to preserve uniqueness. - **Multi-experiment joint Rietveld.** Same shape as the - single-experiment Rietveld block split above, with additional `_pwd_N` - blocks per pattern, all cross-referenced via - `_pd_block_diffractogram_id` and `_pd_block_id` pipe-delimited - identifiers (e.g. `2025-12-06T14:46|binimetinib_3|noname|PubInfo`, - format mirrored from `hb8206.cif`). + single-experiment Rietveld block split above, with one + `data_` block per pattern, all cross-referenced via + `_pd_block_diffractogram_id` and `_pd_block_id`. + + `_pd_block_id` identifies phase/model blocks; in this report that is + the `data_` block ID. `_pd_block_diffractogram_id` + identifies diffractogram/pattern blocks; in this report that is the + `data_` block ID. For single references, emit the + scalar block ID directly (for example `_pd_block_id lbco`, not + `_pd_block_id |lbco|`). User preference recorded for future + multi-block expansion: "If multiple phases/patterns are needed, I'd + rather use a proper loop or move toward the newer pdCIF replacement + fields, not encode lists in one scalar with pipes." - **Sequential fit.** One file per step is **not** the IUCr convention; - sequential refinements emit one `data__pwd_N` block per step - inside the same `reports/.cif`. Natural sequential ordering - matches the multi-pattern Rietveld pattern above. + sequential refinements emit one `data_` block per + step inside the same `reports/.cif`. Natural sequential + ordering matches the multi-pattern Rietveld pattern above. #### 2.3a `data_global` block content @@ -517,37 +530,26 @@ the project has source data, otherwise `?`. - `_easydiffraction_software.*` triple holding the same three roles in structured form, plus `_easydiffraction_software.fit_datetime` when a fit timestamp is available (see §2.3a-i below). -- `_journal.*` placeholders, written as `?` when the project has no - source data: `_journal.name_full`, `_journal.year`, `_journal.volume`, - `_journal.issue`, `_journal.page_first`, `_journal.page_last`, - `_journal.paper_category`, `_journal.paper_DOI`, - `_journal.coden_ASTM`, `_journal.suppl_publ_number`. -- `_journal_date.*` placeholders: `_journal_date.accepted`, - `_journal_date.from_coeditor`, `_journal_date.printers_final`, etc. -- `_journal_coeditor.*` placeholders: `_journal_coeditor.code`, - `_journal_coeditor.name`, `_journal_coeditor.notes`. -- `_publ_contact_author.*` placeholders: `_publ_contact_author.name`, - `_publ_contact_author.address`, `_publ_contact_author.email`, - `_publ_contact_author.phone`, `_publ_contact_author.id_ORCID`, - `_publ_contact_author.id_IUCr`. -- `_publ_author.*` loop placeholders (`_publ_author.name`, - `_publ_author.address`, `_publ_author.footnote`, - `_publ_author.id_ORCID`, `_publ_author.id_IUCr`). -- `_publ_body.*` for section content (`_publ_body.title`, - `_publ_body.contents`). +- No `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`, + `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`, or + `_pd_meas.info_author_*` placeholders are emitted in v1. The clean + report policy and deferred tag list live in + [`project-summary-rendering.md`](project-summary-rendering.md) §5. - `_chemical_formula.*` chemistry summary derived from atom-site data where possible: `_chemical_formula.sum`, `_chemical_formula.moiety`, `_chemical_formula.weight`, `_chemical_formula.IUPAC` (uppercase IUPAC per dictionary). -User-supplied publication metadata override (`publ_info.json` or -similar) is deferred — see Deferred Work. +User-supplied publication metadata (`publ_info.json`, `publ_info.toml`, +or a Python `project.publication` owner) is deferred — see Deferred Work +and the clean report policy in `project-summary-rendering.md` §5. #### 2.3a-i `_easydiffraction_software` framework -The IUCr submission needs to identify the analysis stack. The project -emits one structured category in `data_global` from `analysis.software`, -carrying three role-keyed strings and an optional fit timestamp: +The IUCr-aligned report needs to identify the analysis stack. The +project emits one structured category in `data_global` from +`analysis.software`, carrying three role-keyed strings and an optional +fit timestamp: ``` _easydiffraction_software.framework 'EasyDiffraction 0.17.0' @@ -581,8 +583,8 @@ identification triple above. #### 2.3b Structure-block content (per-block) -For each `data_` (single-crystal) or `data__phase_N` -(powder Rietveld) block: +For each `data_` (single-crystal) or `data_` +(powder Rietveld phase) block: - `_chemical_formula.{moiety, sum, weight, IUPAC}` summary. - `_cell.*` (`length_a`, `angle_alpha`, `volume`, @@ -664,7 +666,7 @@ For TOF experiments, the `_pd_meas.2theta_scan` column is replaced by `_pd_meas.time_of_flight`. Verified against `bal5001.cif` (content set; tag form follows `cif_pow.dic`). -#### 2.3f `data__overall` block (Rietveld only) +#### 2.3f `data_overall` block (Rietveld only) For powder Rietveld files, an `_overall` block carries refinement-level metadata that applies across all phases and patterns: @@ -678,17 +680,21 @@ metadata that applies across all phases and patterns: is applied). - `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, `_refine_ls.number_constraints`. -- `_pd_block_id` pipe-delimited cross-reference values pointing to the - phase and pattern blocks. +- `_pd_block_id` references to phase/model blocks and + `_pd_block_diffractogram_id` references to diffractogram/pattern + blocks. Single references are plain scalar IDs. Multiple + phases/patterns should use a proper loop or newer pdCIF replacement + fields, not pipe-delimited scalar lists. -#### 2.3g `data__pwd_N` block (Rietveld only — constant wavelength) +#### 2.3g `data_` block (Rietveld only — constant wavelength) For each constant-wavelength (CWL) diffraction pattern: - `_pd_meas.*` measurement metadata (`_pd_meas.scan_method`, `_pd_meas.2theta_range_min/max/inc`, `_pd_meas.number_of_points`, - `_pd_meas.datetime_initiated`, - `_pd_meas.info_author_{name, email, phone}` placeholders). + `_pd_meas.datetime_initiated`). The + `_pd_meas.info_author_{name, email, phone}` placeholders are omitted + by the clean report policy. - `_diffrn.*` and `_diffrn_radiation_wavelength.*` (radiation type, probe, wavelength). - `_pd_proc.2theta_range_min/max/inc`, `_pd_proc.info_data_reduction`, @@ -697,7 +703,7 @@ For each constant-wavelength (CWL) diffraction pattern: - The `_pd_meas.*` profile-data loop (§2.3e). - The `_refln.*` reflections loop (§2.3d). -#### 2.3h `data__pwd_N` block (Rietveld only — TOF) +#### 2.3h `data_` block (Rietveld only — TOF) For time-of-flight (TOF) diffraction patterns the block has the same shape as §2.3g, with three TOF-specific substitutions — **all defined in @@ -819,8 +825,9 @@ raising `EasyDiffractionWriterError`), value-type matching against loop columns, and well-formed DDLm dotted form. It never covered crystallographic sanity checks (bond lengths, void volumes, density plausibility, missed-symmetry detection, ADP positive-definiteness) or -whether `?` placeholders in `_journal.*` / `_publ_*` had been filled — -those remain a separate IUCr-server concern. +whether future journal-submission metadata is complete — that remains a +separate IUCr-server concern. The v1 clean report CIF does not emit the +empty `_journal.*` / `_publ_*` placeholders. ### 3. Handler mechanism — `iucr_name` + `IucrCategoryTransformer` @@ -984,17 +991,18 @@ Policy: recognisable to scientists familiar with `_refine_ls.*` / `_pd_proc_ls.*` from Rietveld publications; the IUCr export carries the matching dictionary-canonical category prefixes per topology. -- IUCr submission becomes a single explicit report command, with no - manual editing required: `project.report.save_cif()` produces an - upload-ready file at `reports/.cif` matching the - multi-datablock publication convention. Users who want CIF reports on - every project save can set `project.report.cif = True`. -- Publication-metadata placeholders are emitted as `?` in `data_global` - so users know where to fill in journal-required info before - submission. -- External IUCr tooling (publCIF, checkCIF, pdCIFplotter, - journal-submission pipelines) consumes the submission file cleanly; - the day-to-day saved files are not a tooling target. +- The report CIF becomes a single explicit report command, with no + manual editing required for the refinement data: + `project.report.save_cif()` produces a clean file at + `reports/.cif` matching the multi-datablock publication + convention for structural and fit content. Users who want CIF reports + on every project save can set `project.report.cif = True`. +- Journal and author metadata placeholders are omitted from + `data_global`; a future submission-specific surface can add them when + a concrete journal workflow requires them. +- External IUCr tooling (publCIF, checkCIF, pdCIFplotter) can consume + the report file cleanly; the day-to-day saved files are not a tooling + target. - `_easydiffraction_*` prefix appears only in the IUCr export, where the explicit namespacing aids journal reviewers. It does not bloat day-to-day CIFs. @@ -1122,12 +1130,12 @@ it. ## Deferred Work -- **Publication-metadata override hook.** A user-supplied - `reports/publ_info.json` (or `publ_info.toml`) read by the IUCr report - writer to replace the `?` placeholders in `data_global` (`_journal.*`, - `_publ_*`, `_publ_author.*` loop entries). Out of scope for the first - pass; revisit once the IUCr export is shipping and users have feedback - on workflow friction. +- **Journal-submission metadata surface.** A future ADR may introduce a + user-supplied `reports/publ_info.json` / `publ_info.toml` file or a + Python `project.publication` owner for `_journal.*`, `_publ_*`, and + `_publ_author.*` entries. V1 deliberately omits those placeholders so + generated report CIFs stay clean; revisit only with concrete user or + journal-portal requirements. - **Crystallographic sanity validation.** The §2.5 validator covers spec compliance only. A future pass could integrate IUCr's web checkCIF (HTTP POST to the checkCIF endpoint) or bundle a local subset of its diff --git a/docs/dev/adrs/accepted/minimizer-category-consolidation.md b/docs/dev/adrs/accepted/minimizer-category-consolidation.md index f7a1cdb30..b0f374f19 100644 --- a/docs/dev/adrs/accepted/minimizer-category-consolidation.md +++ b/docs/dev/adrs/accepted/minimizer-category-consolidation.md @@ -115,7 +115,7 @@ owner, category as a read-only attribute that gets swapped. ### 3. Per-parameter posterior data lives on `Parameter.posterior` Adopt the proposal from -[`parameter-posterior-summary.md`](parameter-posterior-summary.md): +[`parameter-posterior-summary.md`](../suggestions/parameter-posterior-summary.md): `GenericParameter.posterior` is `None` for deterministic fits and a `PosteriorParameterSummary` for Bayesian fits. The `_bayesian_parameter_posterior` CIF loop is removed; posterior summary @@ -457,9 +457,9 @@ category's class-level `_engine_metadata` dict. ### Suggestions superseded or absorbed -- [`parameter-posterior-summary.md`](parameter-posterior-summary.md) — - absorbed by §3 of this ADR. When this ADR is accepted, that suggestion - can be closed and a pointer added. +- [`parameter-posterior-summary.md`](../suggestions/parameter-posterior-summary.md) + — absorbed by §3 of this ADR. When this ADR is accepted, that + suggestion can be closed and a pointer added. ## Alternatives Considered diff --git a/docs/dev/adrs/accepted/project-facade-and-persistence.md b/docs/dev/adrs/accepted/project-facade-and-persistence.md index 6d16f7118..7a3fcf66c 100644 --- a/docs/dev/adrs/accepted/project-facade-and-persistence.md +++ b/docs/dev/adrs/accepted/project-facade-and-persistence.md @@ -16,8 +16,8 @@ Persistence. `Project` is the top-level user facade. It owns project metadata, structures, experiments, rendering preferences, display helpers, -analysis, report helpers, publication metadata, verbosity, and save/load -behavior. +analysis, report helpers, verbosity, and save/load behavior. Journal and +publication metadata are deferred from the v1 facade. A later proposal considered renaming this facade to `Workspace` so that `project` could be reserved for the scientific project information @@ -57,12 +57,14 @@ hybrid surface: its scalar output configuration persists to under `reports/`. The previous `project.summary` placeholder and its `summary.cif` output are not part of the persistence layout. -Expose journal-submission metadata as `project.publication`. It is a -top-level owner with CIF-aligned sibling categories for `_journal.*`, +Do not expose journal-submission metadata as `project.publication` in +v1. The clean report policy in +[`project-summary-rendering.md`](project-summary-rendering.md) §5 keeps +`project.cif` and generated report CIFs free of empty `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`, `_publ_contact_author.*`, -`_publ_body.*`, and the `_publ_author.*` loop. These singleton -publication categories persist in `project.cif` and feed report exports; -`reports/.cif` remains export-only. +`_publ_body.*`, `_publ_author.*`, and `_pd_meas.info_author_*` fields. +Those tags are deferred for a future journal-submission metadata +surface. Keep project information available as `project.info`. The Python name avoids a confusing `project.project` access path, while the persisted @@ -90,9 +92,8 @@ serialized project-information field. If the path is exposed in Python, it must not emit a `_project.path` CIF item. The project-level singleton categories currently persisted in -`project.cif` are `_project.*`, `_chart.*`, `_report.*`, `_table.*`, -`_verbosity.*`, `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`, -`_publ_contact_author.*`, `_publ_body.*`, and the `_publ_author.*` loop. +`project.cif` are `_project.*`, `_rendering_plot.*`, `_report.*`, +`_rendering_table.*`, and `_verbosity.*`. ## Consequences diff --git a/docs/dev/adrs/accepted/project-summary-rendering.md b/docs/dev/adrs/accepted/project-summary-rendering.md index 8098b6d12..85e9e72b0 100644 --- a/docs/dev/adrs/accepted/project-summary-rendering.md +++ b/docs/dev/adrs/accepted/project-summary-rendering.md @@ -18,20 +18,20 @@ Runs alongside, and **extends**, the accepted - A `project.save(report=True)` opt-in flag for the IUCr CIF. That ADR currently scopes `project.report` to **CIF only** — the -multi-datablock IUCr submission CIF written to `reports/.cif`. -This ADR keeps the facade and adds a **`project.report` configuration -category** with five scalar persisted fields (`cif`, `html`, `tex`, -`pdf`, `html_offline`) on `project.cif`, plus ad-hoc per-format methods -(`save_html()`, `save_cif()`, `save_tex()`, `save_pdf()`). The -Python-side API uses those same boolean descriptors directly, matching -the persisted CIF shape. The LaTeX writer hardcodes `iucrjournals` as -its document class — there is no style selector, no `_report.style` -field, no `style=` arg on `save_tex()` / `save_pdf()`. The accepted IUCr -`project.save(report=True)` flag is **removed**; reports come from the -config category, not from boolean flags. All four format booleans -default to `False` so `project.save()` writes nothing under `reports/` -until the user configures otherwise, preserving the "no surprise files" -property. +multi-datablock IUCr-aligned report CIF written to +`reports/.cif`. This ADR keeps the facade and adds a +**`project.report` configuration category** with five scalar persisted +fields (`cif`, `html`, `tex`, `pdf`, `html_offline`) on `project.cif`, +plus ad-hoc per-format methods (`save_html()`, `save_cif()`, +`save_tex()`, `save_pdf()`). The Python-side API uses those same boolean +descriptors directly, matching the persisted CIF shape. The LaTeX writer +hardcodes `iucrjournals` as its document class — there is no style +selector, no `_report.style` field, no `style=` arg on `save_tex()` / +`save_pdf()`. The accepted IUCr `project.save(report=True)` flag is +**removed**; reports come from the config category, not from boolean +flags. All four format booleans default to `False` so `project.save()` +writes nothing under `reports/` until the user configures otherwise, +preserving the "no surprise files" property. Coordination points with the alignment ADR (no blocking conflicts; its Open Questions section is empty): @@ -50,18 +50,17 @@ Open Questions section is empty): `project.save()` (§1.4). HTML, TeX, and PDF outputs are not gemmi-validatable and get no pre-write validation; LaTeX errors surface at PDF-compile time via the TeX engine. A writer that emits - non-compliant CIF raises `EasyDiffractionWriterError` instead. This - ADR's deferred `check_completeness()` (publication-side completeness) - is a separate concern that stays in Deferred Work. -- **Publication metadata source** — the alignment ADR's Deferred Work - proposes a user-supplied `reports/publ_info.{toml,json}` to replace - `?` placeholders. Both write paths read the same Python attribute, - **`project.publication`** — a new top-level on `Project`, sibling to - `project.info` and `project.analysis`. The schema is defined in §5 of - this ADR: six CIF-aligned sibling categories (`journal`, - `journal_date`, `journal_coeditor`, `contact_author`, `body`, - `authors`) with full IUCr-tag fidelity. The loader accepts TOML - (primary) and JSON (fallback); selection is by file extension. + non-compliant CIF raises `EasyDiffractionWriterError` instead. + Completeness checks for a future journal-submission metadata surface + stay in Deferred Work and are not part of the v1 clean report CIF. +- **Clean report metadata** — the alignment ADR's Deferred Work proposed + user-supplied `reports/publ_info.{toml,json}` data to replace `?` + placeholders. This ADR rejects that v1 surface. There is no + `project.publication` owner in v1, `project.cif` does not persist + journal/publication metadata, and the report CIF does not emit empty + journal, author, publication-body, or powder-measurement author + placeholders. Section 5 records the deferred tags so they can be + reconsidered later without keeping empty fields in current reports. Also touches: @@ -74,26 +73,25 @@ Also touches: - [`project-facade-and-persistence.md`](../accepted/project-facade-and-persistence.md) — two changes: `project.report` gains a persisted configuration category (`_report.*` in `project.cif`, see §1.3), turning the facade - into a hybrid of helper methods plus persisted config; and a new - top-level `project.publication` owner is added alongside the existing - `project.info`, `project.structures`, `project.experiments`, - `project.analysis`, `project.report` facade slots (see §5). + into a hybrid of helper methods plus persisted config; and the + previously proposed `project.publication` owner is rejected for v1 so + empty journal metadata stays out of `project.cif` (see §5). - [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) - — owns the Python↔CIF correspondence rule for **two** new - project-level singleton surfaces: `project.report.* ↔ _report.*` (five - scalar items, §1.3) and `project.publication.*` sibling categories ↔ - `_journal.*`, `_publ_author.*`, `_publ_contact_author.*`, etc. (§5). + — owns the Python↔CIF correspondence rule for the new + `project.report.* ↔ _report.*` project-level singleton surface (five + scalar items, §1.3). The rejected `project.publication.*` surface is + recorded in §5 for future reconsideration, not accepted for v1. ## Context The library today has four shapes of summary output: -- `Report.show_report()` and friends — terminal/Jupyter rendering of - project metadata, crystallographic data per phase, experimental +- `project.report` — report configuration and per-format export methods + for project metadata, crystallographic data per phase, experimental configuration, and fit metrics - ([report.py](../../../../src/easydiffraction/report/report.py)). - (Pre-PR #184 this was `Summary.show_report()` on `project.summary`; - the IUCr alignment ADR replaced the unimplemented placeholder.) + ([default.py](../../../../src/easydiffraction/project/categories/report/default.py)). + The IUCr alignment ADR replaced the earlier unimplemented + `project.summary` placeholder with this facade. - `summary.cif` — was written into the project root on every `project.save()` as the literal string `"To be added..."` until PR #184 removed both the writer call and the placeholder method. Not a @@ -127,18 +125,16 @@ the unresolved design question. The alignment ADR has since replaced the unimplemented `project.summary` slot with a `project.report` facade scoped to IUCr CIF generation (`reports/.cif`). That resolves the CIF half of the question but leaves the GUI Summary tab, the -terminal `show_report()`, the human-readable HTML, and the -manuscript-bound LaTeX/PDF without a definition. This ADR fills the gap -by extending the same `project.report` facade with non-CIF rendering -surfaces. +human-readable HTML, and the manuscript-bound LaTeX/PDF without a +definition. This ADR fills the gap by extending the same +`project.report` facade with non-CIF rendering surfaces. ## Scope In scope: -- Extend the alignment ADR's `project.report` facade with - terminal/Jupyter, HTML, and LaTeX rendering surfaces, a configuration - category (five scalar fields — +- Extend the alignment ADR's `project.report` facade with HTML and LaTeX + rendering surfaces, a configuration category (five scalar fields — `project.report.{cif, html, tex, pdf, html_offline}` — persisted in `project.cif`), and ad-hoc per-format save methods. **All report formats are opt-in via the configuration; every format defaults to @@ -152,12 +148,11 @@ In scope: Persisted in `analysis/analysis.cif` (amends the IUCr ADR's "Analysis — unchanged" stance for these fields; see §4 and the ADRs-amended list). -- Add a new top-level `project.publication` owner on `Project` (sibling - to `project.info`, `project.structures`, `project.experiments`, - `project.analysis`, `project.report`) carrying the `_publ_*` / - `_journal_*` publication metadata the IUCr writer otherwise emits as - `?` placeholders. See §5; amends `project-facade-and-persistence.md` - and complements `python-cif-category-correspondence.md`. +- Reject the previously proposed top-level `project.publication` owner + for v1. Journal, author, publication-body, and powder-measurement + author tags are not represented in code, are not persisted in + `project.cif`, and are not emitted as empty fields in the report CIF. + See §5 for the deferred tag list and the retained report-CIF fields. - Ship exactly one LaTeX style (`iucrjournals`) — no style selector, no `ReportStyleEnum`, no `_report.style` field. Multi-style support (REVTeX, Elsevier, etc.) is deferred to a follow-up ADR; see "Deferred @@ -168,11 +163,12 @@ Out of scope: - CIF tag-name decisions for any serialised field. Those are the alignment ADR's job; this ADR notes recommended mappings and cross-references. -- The IUCr CIF submission export tag policy and multi-datablock layout. - Covered by the alignment ADR; the output file lives at +- The IUCr-aligned report CIF export tag policy and multi-datablock + layout. Covered by the alignment ADR; the output file lives at `reports/.cif` and is opt-in via `project.report.cif = True`. - Pre-existing project-level singleton categories (`_info.*`, - `_chart.*`, `_table.*`, `_verbosity.*`). Covered by the in-flight + `_rendering_plot.*`, `_rendering_table.*`, `_verbosity.*`). Covered by + the in-flight [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md). This ADR **does** add one new project-level singleton category, `_report.*`, alongside them (see §1.3 and the ADRs-amended list); that @@ -213,9 +209,9 @@ The alignment ADR has already created the `project.report` facade extends it along two axes: - A new **configuration category** on `project.report` — persisted in - `project.cif`, matching the existing `project.chart`, `project.table`, - `project.verbosity` config pattern — that records _which_ report - formats `project.save()` emits and _how_. + `project.cif`, matching the existing `project.rendering_plot`, + `project.rendering_table`, `project.verbosity` config pattern — that + records _which_ report formats `project.save()` emits and _how_. - A new set of **ad-hoc per-format methods** for explicit one-off writes that bypass the configuration. @@ -242,9 +238,10 @@ read by `project.save()` thereafter: Four per-format scalar booleans (`cif`, `html`, `tex`, `pdf`) plus `html_offline` — **five fields total**, all single-row in CIF. Matches -the existing `project.chart`, `project.table`, `project.verbosity` -scalar-config shape verbatim. All booleans default to `False`, so an -unconfigured project produces no `reports/` directory at all. +the existing `project.rendering_plot`, `project.rendering_table`, +`project.verbosity` scalar-config shape verbatim. All booleans default +to `False`, so an unconfigured project produces no `reports/` directory +at all. There is no `style` field. The LaTeX output ships exactly one class (`iucrjournals`); adding another style is deferred work, not a v1 @@ -321,13 +318,6 @@ project.report.as_tex() -> str # Shared data context (for GUI Summary tab + Jinja templates): project.report.data_context() -> dict -# Terminal / Jupyter renderers (existing methods, migrated to -# project.report by PR #184 — names preserved): -project.report.show_report() # full report — sections below -project.report.show_project_info() -project.report.show_crystallographic_data() -project.report.show_experimental_data() -project.report.show_fitting_details() ``` Per-format method signatures only carry the args that apply to that @@ -388,9 +378,9 @@ unconditionally. They are explicit one-offs. #### 1.3 CIF persistence of the configuration The configuration category serialises to `project.cif` next to the other -project-level singleton categories (`_info.*`, `_chart.*`, `_table.*`, -`_verbosity.*`). The CIF tag prefix is `_report.*` — a Set category with -five scalar items, no loops: +project-level singleton categories (`_info.*`, `_rendering_plot.*`, +`_rendering_table.*`, `_verbosity.*`). The CIF tag prefix is `_report.*` +— a Set category with five scalar items, no loops: ```text data_ @@ -402,8 +392,8 @@ _info.created 2026-05-26T12:00:00 _info.last_modified 2026-05-26T15:42:00 # ---- Chart / table / verbosity selectors (existing config) ---- -_chart.type plotly -_table.type plotly +_rendering_plot.type plotly +_rendering_table.type plotly _verbosity.fit short # ---- Report configuration (this ADR §1.3) ---- @@ -416,9 +406,9 @@ _report.html_offline no All five items are scalar DDLm dotted entries — the category is declared `_definition.class Set` so a single value per item, no loops permitted. -Matches the existing `_chart.*`, `_table.*`, `_verbosity.*` category -shape exactly. The `yes`/`no` boolean encoding follows the project's -existing CIF boolean convention. +Matches the existing `_rendering_plot.*`, `_rendering_table.*`, +`_verbosity.*` category shape exactly. The `yes`/`no` boolean encoding +follows the project's existing CIF boolean convention. The default unconfigured state writes four explicit `no` values for the format booleans (not an absent or empty representation), so the "no @@ -460,17 +450,17 @@ The project already has two distinct facade patterns for top-level config — not Pattern B — heavy datablock owner with its own CIF file. The split is summarised below. -| Slot | Pattern | CIF location | Python shape | -| ------------------------------------ | ------- | ---------------------------------------- | ---------------------------------------------------- | -| `project.info` | A | `project.cif` (`_info.*`) | small `CategoryItem` | -| `project.chart` | A | `project.cif` (`_chart.*`) | `CategoryItem` (one field) | -| `project.table` | A | `project.cif` (`_table.*`) | `CategoryItem` (one field) | -| `project.verbosity` | A | `project.cif` (`_verbosity.*`) | `CategoryItem` (one field) | -| **`project.report`** (this ADR) | **A** | **`project.cif` (`_report.*`)** | **`CategoryItem` (five fields) plus action methods** | -| `project.publication` (this ADR, §5) | A | `project.cif` (`_publ_*` / `_journal_*`) | `CategoryOwner` of six sibling categories | -| `project.analysis` | B | `analysis/analysis.cif` | `CategoryOwner` (heavy datablock) | -| `project.structures[name]` | B | `structures/.cif` | `CategoryOwner` (heavy datablock) | -| `project.experiments[name]` | B | `experiments/.cif` | `CategoryOwner` (heavy datablock) | +| Slot | Pattern | CIF location | Python shape | +| ------------------------------------ | ------- | ------------------------------------ | ---------------------------------------------------- | +| `project.info` | A | `project.cif` (`_info.*`) | small `CategoryItem` | +| `project.rendering_plot` | A | `project.cif` (`_rendering_plot.*`) | `CategoryItem` (one field) | +| `project.rendering_table` | A | `project.cif` (`_rendering_table.*`) | `CategoryItem` (one field) | +| `project.verbosity` | A | `project.cif` (`_verbosity.*`) | `CategoryItem` (one field) | +| **`project.report`** (this ADR) | **A** | **`project.cif` (`_report.*`)** | **`CategoryItem` (five fields) plus action methods** | +| `project.publication` (rejected, §5) | n/a | none | no v1 Python surface | +| `project.analysis` | B | `analysis/analysis.cif` | `CategoryOwner` (heavy datablock) | +| `project.structures[name]` | B | `structures/.cif` | `CategoryOwner` (heavy datablock) | +| `project.experiments[name]` | B | `experiments/.cif` | `CategoryOwner` (heavy datablock) | Reasons `project.report` is Pattern A, not Pattern B: @@ -483,14 +473,13 @@ Reasons `project.report` is Pattern A, not Pattern B: configuration, which already share `project.cif` for the same reason — they are all project-level preferences, not domain data. -What makes `project.report` look heavier than `project.chart` / -`project.table` / `project.verbosity` is the action methods on the -facade (`save_cif()`, `save_html()`, `show_report()`, `data_context()`, -etc.). Those live on the Python class alongside the configuration -fields, which is the facade-hybrid amendment to -`project-facade-and-persistence.md` already recorded in the ADRs-amended -list. The action methods do not change where the configuration persists -— that stays in `project.cif`. +What makes `project.report` look heavier than `project.rendering_plot` / +`project.rendering_table` / `project.verbosity` is the action methods on +the facade (`save_cif()`, `save_html()`, `data_context()`, etc.). Those +live on the Python class alongside the configuration fields, which is +the facade-hybrid amendment to `project-facade-and-persistence.md` +already recorded in the ADRs-amended list. The action methods do not +change where the configuration persists — that stays in `project.cif`. #### 1.4 Validation moves internal — CIF only, writer-correctness only @@ -550,11 +539,10 @@ overhead to a one-time ~200 ms session cost. The `EasyDiffractionWriterError` includes the full gemmi diagnostic so bug reports are actionable. -A separate, _completeness_-oriented check -(`project.report.check_completeness()`) — flagging unfilled `_publ_*` / -`_journal_*` placeholders for journal submission, which is a -publication-readiness question rather than a writer-correctness one — is -a different concern and stays in Deferred Work. +A separate, _completeness_-oriented check for a future +journal-submission metadata surface is a different concern and stays in +Deferred Work. The v1 report does not expose or fill `_publ_*` / +`_journal_*` placeholders. #### 1.5 Descriptor display metadata — `DisplayHandler` @@ -651,7 +639,7 @@ self._u_iso = Parameter( | ---------------------------------------- | --------------------------- | ---------------------------- | | LaTeX (`save_tex`) | `$U_{\mathrm{iso}}$` | `\AA$^2$` | | HTML (`save_html`, MathJax-rendered) | `$U_{\mathrm{iso}}$` | `\AA$^2$` | -| HTML pre-MathJax / GUI / `show_report()` | `Uiso` | `Ų` | +| HTML pre-MathJax / GUI | `Uiso` | `Ų` | | `project.report.data_context()` raw dict | both available | both available | | CIF emission | `_atom_site.U_iso_or_equiv` | (no `_units.code` row today) | | Python code / repr | `u_iso` | `angstrom_squared` | @@ -670,9 +658,9 @@ per-context fallback chain: additionally surrounds `handler.latex_name` / `handler.latex_units` with `\(...\)` math delimiters so MathJax picks them up where the descriptor has typeset variants — i.e., HTML can show the same - `$U_{\mathrm{iso}}$` the PDF shows, while a GUI tooltip or - `show_report()` printout falls back to `display_*`. -- **GUI / terminal / `show_*()` context**: + `$U_{\mathrm{iso}}$` the PDF shows, while a GUI tooltip falls back to + `display_*`. +- **GUI / plain-text context**: `handler.display_name or descriptor.name`, `handler.display_units or descriptor.units`. @@ -830,9 +818,11 @@ rather than maintaining hand-written summary tables: descriptors first as key-value tables and loop items as loop tables with headers. Experiment data categories (`pd_data`, `total_data`, `refln`) are skipped because they are plotted or too large for report - tables. The fit-quality plot remains the first experiment - sub-subsection, and publication metadata remains source data only — it - is not added to HTML, TeX, or PDF reports. + tables. The structure view and the fit-quality plot sit directly under + their parent subsection — the structure or experiment record (e.g. + `3.1 lbco`, `4.1 hrpt`) — as its first content, with no extra + sub-subsection heading. Publication metadata remains source data only + — it is not added to HTML, TeX, or PDF reports. - **DisplayHandler names and units.** All table labels and units use the per-context `DisplayHandler` resolution chain, so TeX sees LaTeX names (`$2\theta$ offset`, `$U_{\mathrm{iso}}$`), HTML sees MathJax-capable @@ -867,6 +857,14 @@ rather than maintaining hand-written summary tables: report styling code and passed to HTML CSS, TeX tables, and Plotly/pgfplots figures. Body rows alternate with the first body row filled, regardless of whether the table has a header. +- **Framed structure figure.** In the TeX/PDF report the structure view + is wrapped in an `\fcolorbox` that is always the full line width; the + raster PNG is scaled to fit within half the text height or the line + width, whichever binds first (aspect preserved), so the box height + follows the image automatically. The frame uses the same light grey as + the interactive view's container, keeping the static report figure and + the Jupyter view visually consistent. The HTML report keeps the + interactive Three.js view, which already draws that container border. - **Predictable table widths.** HTML and TeX key-value tables use at least half of the available text width. Loop tables are classified from their rendered content: compact loops use half width, while wider @@ -893,9 +891,9 @@ Rationale for the config category (replacing the earlier flag-based and "auto on every save" positions): - Reports are a _project preference_, not a per-call argument. - `project.chart.type`, `project.table.type`, `project.verbosity.fit` - follow the same pattern — set once, persisted in `project.cif`, - applied on every save. + `project.rendering_plot.type`, `project.rendering_table.type`, + `project.verbosity.fit` follow the same pattern — set once, persisted + in `project.cif`, applied on every save. - `project.save()` has one job: save the project. With all report booleans `False`, the report behaviour is unchanged from before this ADR; with `project.report.html = True`, HTML appears on every save @@ -1061,7 +1059,7 @@ Single style (`iucrjournals`) — no multi-style infrastructure. # reports/ directory does not exist ``` -`project.report.cif = True` (journal-submission CIF only): +`project.report.cif = True` (clean IUCr-aligned report CIF only): ``` / @@ -1086,7 +1084,7 @@ inspection page): .html # ~3 MB, Plotly inlined ``` -`project.report.cif/html/pdf = True` (typical pre-submission bundle): +`project.report.cif/html/pdf = True` (typical review bundle): ``` / @@ -1095,7 +1093,7 @@ inspection page): experiments/<...>.cif analysis/analysis.cif reports/ - .cif # journal-submission CIF + .cif # clean IUCr-aligned report CIF .html # interactive inspection page .pdf # typeset PDF, iucrjournals class tex/ # source for the PDF (kept editable) @@ -1154,9 +1152,8 @@ when there is a concrete second style to ship. The generated document uses the project title directly in `\title{...}` and emits an empty `\author{}`. If `project.info.description` is non-empty, that text becomes the document abstract; if it is empty, the -abstract environment is omitted. Publication metadata -(`project.publication.*`) is not included in the human-readable HTML or -TeX/PDF reports. +abstract environment is omitted. Journal/publication metadata is not +included in the human-readable HTML or TeX/PDF reports. #### 3.2.1 Source provenance and bundled files @@ -1556,155 +1553,110 @@ successful return — pre-fit calls, failed fits, and projects loaded from a save predating this ADR all start out **without** the snapshot. The public API surface treats missing provenance uniformly: -- **Rendering (`project.report.show_report()`, HTML, TeX).** Each - role-row prints `"(not available)"` for `name`, omits version and URL, - and adds a one-line footer "Software-provenance snapshot not yet - recorded — call `Analysis.fit()` once to populate." No warning, no - exception; the report still renders end-to-end so users iterating on a - configuration before fitting see the rest of the page. +- **Rendering (HTML, TeX).** Each role-row prints `"(not available)"` + for `name`, omits version and URL, and adds a one-line footer + "Software-provenance snapshot not yet recorded — call `Analysis.fit()` + once to populate." No warning, no exception; the report still renders + end-to-end so users iterating on a configuration before fitting see + the rest of the page. - **IUCr CIF export (`project.report.cif = True`).** The - `_easydiffraction_software.{framework, calculator, minimizer}` triple - emits `?` placeholders consistent with the IUCr ADR's unset-field - convention. The derived `_computing.structure_refinement` string falls - back to `"EasyDiffraction "` (framework only). The - `_easydiffraction_software.fit_datetime` tag is omitted entirely (no - `?` — the absence is the signal). + `_computing.structure_refinement` string falls back to + `"EasyDiffraction "` when calculator or minimizer provenance + is unavailable. `_easydiffraction_software.framework` is emitted from + the framework label; `_easydiffraction_software.calculator`, + `_easydiffraction_software.minimizer`, and + `_easydiffraction_software.fit_datetime` are emitted only when the + corresponding fit snapshot values exist. - **Internal validation (the §1.4 pre-write gemmi pass).** Does **not** detect missing provenance. The gemmi pass validates that emitted tags and types match the IUCr core / pdCIF dictionaries, and it explicitly skips the `_easydiffraction_*` extension namespace. So the fallback-filled `_computing.structure_refinement` (`"EasyDiffraction "`) is not flagged as missing — it's a - valid string — and the `?` placeholders on - `_easydiffraction_software.{framework, calculator, minimizer}` are not - flagged either, because gemmi does not validate the extension - namespace. Detecting "publication-grade provenance is incomplete" is a - different concern from dictionary-spec compliance and falls to the - deferred `project.report.check_completeness()` listed in Deferred - Work. Users who must guarantee complete provenance before submission - should run that check (once it lands) or inspect the rendered report - manually. + valid string — and omitted optional `_easydiffraction_software.*` + fields are not flagged because gemmi does not validate the extension + namespace. Detecting complete provenance is a different concern from + dictionary-spec compliance and falls to a deferred completeness check. + Users who must guarantee complete provenance should run that check + (once it lands) or inspect the rendered report manually. - **Old projects.** Loading a project saved before this ADR produces an `analysis.software` with all fields unset and the timestamp `None`. No migration step is run; the user populates the snapshot by re-running the fit. This rule applies wholesale — no flag toggles it, no targeted exception -is raised. Publication-grade users who need the provenance can re-run -the fit; users producing draft / preview reports keep working without -interruption. - -### 5. Publication-metadata category on `project` - -New top-level category on `Project`, sibling to `project.info` and -`project.analysis`. Populated by the user (directly in Python, or loaded -from a `publ_info.{toml,json}` file). Feeds the `_publ_*` / `_journal_*` -/ `_publ_author.*` placeholders that the alignment ADR's `data_global` -block currently emits as `?` (alignment ADR §2.3a). - -#### 5.1 Structure — CIF-aligned sibling categories - -`Publication` is a category-owner (like `Experiment`) hosting sibling -sub-categories that map **1:1 to CIF category prefixes**. No artificial -groupings; the CIF dictionaries already provide the natural shape: - -| Python attribute | CIF category | Audience | -| ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| `publication.journal` | `_journal.*` | User-set at submission (`name_full`, `paper_category`); editor-set post-acceptance (`year`, `volume`, `issue`, `page_*`, `paper_doi`) | -| `publication.journal_date` | `_journal_date.*` | Editor (`accepted`, `from_coeditor`, `printers_final`) | -| `publication.journal_coeditor` | `_journal_coeditor.*` | Editor (`code`, `name`, `notes`) | -| `publication.contact_author` | `_publ_contact_author.*` | User (`name`, `address`, `email`, `phone`, `id_orcid`, `id_iucr`) | -| `publication.body` | `_publ_body.*` | User (`title`, `synopsis`, `abstract`, `keywords`) | -| `publication.authors` | `_publ_author.*` (loop) | User (per author: `name`, `address`, `footnote`, `id_orcid`, `id_iucr`) | - -Access pattern: - -```python -project.publication.journal.name_full = "Acta Crystallographica E" -project.publication.journal.paper_category = "structure-report" -project.publication.journal.paper_doi = "10.1107/S2056989026..." # post-acceptance -project.publication.contact_author.name = "Jane Doe" -project.publication.contact_author.email = "jane@example.com" -project.publication.contact_author.id_orcid = "0000-0001-..." -project.publication.body.title = "Crystal structure of ..." -project.publication.body.synopsis = "Short summary..." -project.publication.body.abstract = "..." -project.publication.body.keywords = ["powder diffraction", "Rietveld", ...] -project.publication.authors.add(name="Jane Doe", id_orcid="0000-0001-...") -project.publication.authors.add(name="John Smith", id_orcid="0000-0002-...") -``` - -Python attributes are lowercase snake_case (`id_orcid`); CIF tags retain -dictionary casing (`_publ_contact_author.id_ORCID`). Loops use the -project's existing `CategoryCollection` pattern (`add()`, indexed -access, etc.). - -The editor-side categories (`journal_date`, `journal_coeditor`) exist so -the schema can carry editor-supplied fields when a user manually copies -them in (typically by editing `project.publication.*` in Python after a -referee round) — not because users typically fill them at submission -time. Defaults are `None`; the IUCr writer emits `?` for unset fields. -Round-trip is on the **`project.cif`** axis only (`project.publication` -reads and writes there per the project-facade-and-persistence contract); -**the report CIF at `reports/.cif` stays export-only** per the -accepted IUCr ADR. A reader for `reports/.cif` is explicitly -out of scope. - -#### 5.2 Discrete `body` fields, not a markdown blob - -`_publ_body.*` in coreCIF supports nested section content via an -`element` / `format` / `contents` trio. For the refinement-table -appendix use case (user pastes content into a full manuscript later), -discrete top-level slots are more discoverable than a generic markdown -blob: - -- `publication.body.title` — manuscript title (single string) -- `publication.body.synopsis` — short summary (IUCr Acta E requires - this) -- `publication.body.abstract` — abstract text -- `publication.body.keywords` — list of strings (loop on CIF side) - -A future v2 could add free-form section support -(`publication.body.sections[]` with element/format/contents trios) when -users produce full manuscripts from the library. Out of scope for v1. - -#### 5.3 Input mechanism — TOML primary, JSON fallback - -Two import paths, both writing into the same in-memory -`project.publication` object: - -```python -# Direct Python edit (preferred for notebook / interactive use): -project.publication.contact_author.email = "jane@example.com" - -# File-based load (preferred for collaborative / batch workflows): -project.publication.load("reports/publ_info.toml") # TOML, by extension -project.publication.load("reports/publ_info.json") # JSON, by extension -``` - -TOML is the primary format: - -- **Comment support** — users can document why a field is set / unset. -- **Multi-line strings** — abstracts, addresses, and synopses without - escaping. -- **Familiar** — the project already uses `pyproject.toml` and - `pixi.toml`. -- **Standard library** `tomllib` (Python 3.11+); no new dependency. - -JSON is supported as a fallback for programmatic generation (e.g. a user -script that dumps publication data from a database; an external tool -that produces JSON output). Standard library `json`. - -Format selection is by file extension. Unknown extensions raise -`ValueError("Unsupported publication-info format: . " "Use .toml or .json.")`. -No YAML support — adds a dependency for no gain. - -Reading from the report CIF (`reports/.cif`) back into -`project.publication` is **explicitly out of scope** here and remains -the accepted IUCr ADR's "Export only — no round-trip" contract. Users -who edit the report file by hand should also update -`project.publication` (or its TOML/JSON source) so the next save -reflects the edits; the library does not auto-import. +is raised. Users who need full provenance can re-run the fit; users +producing draft / preview reports keep working without interruption. + +### 5. Clean report-CIF metadata policy + +The v1 report CIF is a clean refinement report, not a journal-submission +manuscript stub. It must not create a `project.publication` API surface, +must not persist journal/publication metadata in `project.cif`, and must +not emit report-CIF sections that only contain `?` placeholders. + +The report writer keeps only data-backed scientific content and +generation/provenance metadata. Optional tags with no source value are +omitted rather than written as empty fields. CIF unknown markers remain +allowed only inside an otherwise useful emitted category or loop row +where the dictionary shape requires a cell and dropping the row would +lose real project data. + +#### 5.1 Deferred journal and author tags + +These tags are explicitly **not** part of the v1 code surface, default +`project.cif`, or generated report CIF. They are recorded here for a +future journal-submission ADR to reconsider against a concrete target +journal or portal. + +- `_journal.name_full`, `_journal.year`, `_journal.volume`, + `_journal.issue`, `_journal.page_first`, `_journal.page_last`, + `_journal.paper_category`, `_journal.paper_DOI`, + `_journal.coden_ASTM`, `_journal.suppl_publ_number`. +- `_journal_date.accepted`, `_journal_date.from_coeditor`, + `_journal_date.printers_final`. +- `_journal_coeditor.code`, `_journal_coeditor.name`, + `_journal_coeditor.notes`. +- `_publ_contact_author.name`, `_publ_contact_author.address`, + `_publ_contact_author.email`, `_publ_contact_author.phone`, + `_publ_contact_author.id_ORCID`, `_publ_contact_author.id_IUCr`. +- `_publ_author.name`, `_publ_author.address`, `_publ_author.footnote`, + `_publ_author.id_ORCID`, `_publ_author.id_IUCr`. +- `_publ_body.title`, `_publ_body.synopsis`, `_publ_body.abstract`, + `_publ_body.keywords`, `_publ_body.contents`. +- `_pd_meas.info_author_name`, `_pd_meas.info_author_email`, + `_pd_meas.info_author_phone`. + +The previously proposed `publ_info.{toml,json}` loader is deferred with +these tags. It would be useful only when the library commits to a +submission-oriented publication metadata surface; it is unnecessary for +a clean report CIF. + +#### 5.2 Kept report-CIF fields and rationale + +The report CIF keeps the following tag families when the project has +source data for them. + +| Tag family | Tags retained | Rationale | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Audit | `_audit.creation_method`, `_audit.creation_date` | Identifies the EasyDiffraction writer and report-generation time. These are generated by the library, not user-authored empty metadata. | +| Software provenance | `_computing.structure_refinement`; `_easydiffraction_software.framework`, `_easydiffraction_software.calculator`, `_easydiffraction_software.minimizer`, `_easydiffraction_software.fit_datetime` | Records the analysis stack in a standard IUCr text field plus structured EasyDiffraction fields. `fit_datetime` is emitted only when a fit snapshot exists. | +| Chemical formula | `_chemical_formula.sum`, `_chemical_formula.moiety`, `_chemical_formula.weight`, `_chemical_formula.IUPAC` | Summarises chemistry derived from structure atom sites. These fields describe the refined model, not journal administration. | +| Unit cell | `_cell.length_a`, `_cell.length_b`, `_cell.length_c`, `_cell.angle_alpha`, `_cell.angle_beta`, `_cell.angle_gamma` | Core crystallographic model parameters needed to understand and reuse a structure block. | +| Space group | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`, `_space_group.crystal_system`; `_space_group_symop.id`, `_space_group_symop.operation_xyz` | Gives symmetry in standard coreCIF form and includes explicit operations for downstream tools. | +| Atom sites | `_atom_site.label`, `_atom_site.type_symbol`, `_atom_site.fract_x`, `_atom_site.fract_y`, `_atom_site.fract_z`, `_atom_site.occupancy`, `_atom_site.ADP_type`, `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv`, `_atom_site.Wyckoff_symbol` | Carries the refined structural model. The B/U split follows the accepted ADP policy and dictionary names. | +| Anisotropic ADPs | `_atom_site_aniso.label`, `_atom_site_aniso.B_11`, `_atom_site_aniso.B_22`, `_atom_site_aniso.B_33`, `_atom_site_aniso.B_12`, `_atom_site_aniso.B_13`, `_atom_site_aniso.B_23`, or the matching `U_*` items | Carries anisotropic displacement parameters when present, using one ADP family per emitted loop. | +| Diffraction conditions | `_diffrn.ambient_temperature`, `_diffrn.ambient_pressure`, `_diffrn_radiation.probe`, `_diffrn_radiation_wavelength.id`, `_diffrn_radiation_wavelength.value`, `_diffrn_radiation_wavelength.wt` | Describes measurement conditions and wavelength/probe information needed to interpret the refinement. | +| Single-crystal refinement | `_refine_ls.R_factor_all`, `_refine_ls.wR_factor_all`, `_refine_ls.R_factor_gt`, `_refine_ls.wR_factor_gt`, `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, `_refine_ls.number_constraints`, `_refine_ls.extinction_method`, `_refine_ls.extinction_coef`, `_refine.special_details` | Reports standard single-crystal fit quality and extinction details produced by the refinement state. | +| Reflection summary | `_reflns.number_total`, `_reflns.number_gt`, `_reflns.threshold_expression` | Summarises the reflection set used for single-crystal quality metrics. | +| Single-crystal reflections | `_refln.index_h`, `_refln.index_k`, `_refln.index_l`, `_refln.F_squared_meas`, `_refln.F_squared_calc`, `_refln.F_squared_meas_su`, `_refln.include_status` | Provides the measured/calculated reflection data needed to inspect the fit. | +| Powder block cross-references | `_pd_block_id`, `_pd_block_diffractogram_id`, `_pd_phase_block.id`, `_pd_phase_block.scale` | Links `data_overall`, phase/model blocks, and diffractogram/pattern blocks in multi-block powder reports. The block-name and scalar-reference policy is defined in the CIF-alignment ADR §2.3. | +| Powder measurement and profile | `_pd_meas.scan_method`, `_pd_meas.number_of_points`, `_pd_meas.2theta_scan` or `_pd_meas.time_of_flight`, `_pd_meas.intensity_total`, `_pd_calc.intensity_total`, `_pd_proc.intensity_bkg_calc`, `_pd_proc_ls.weight` | Carries the observed, calculated, background, and weight arrays for profile inspection. The author-info placeholders are excluded by §5.1. | +| Powder processing | `_pd_proc.info_data_reduction`, `_pd_proc.info_datetime`, `_pd_proc.info_excluded_regions` | Documents processing state that affects the profile fit. Empty free-text fields are omitted until real source data exists. | +| Powder refinement | `_pd_calc.method`, `_pd_proc_ls.prof_R_factor`, `_pd_proc_ls.prof_wR_factor`, `_pd_proc_ls.prof_wR_expected`, `_pd_proc_ls.profile_function`, `_pd_proc_ls.background_function`, `_refine_ls.number_parameters`, `_refine_ls.number_restraints`, `_refine_ls.number_constraints` | Reports Rietveld method, profile quality metrics, and model-size counts in standard pdCIF/coreCIF fields. | +| Powder reflections | `_refln.index_h`, `_refln.index_k`, `_refln.index_l`, `_refln.F_squared_meas`, `_refln.F_squared_calc`, `_pd_refln.phase_id`, `_refln.d_spacing` | Keeps calculated powder reflection information tied to the contributing phase. | +| TOF calibration | `_pd_calib_d_to_tof.id`, `_pd_calib_d_to_tof.power`, `_pd_calib_d_to_tof.coeff`, `_pd_calib_d_to_tof.coeff_su`, `_pd_calib_d_to_tof.diffractogram_id` | Required for time-of-flight powder reports when non-zero calibration coefficients are present. | +| EasyDiffraction extensions | `_easydiffraction_experiment_type.sample_form`, `_easydiffraction_experiment_type.beam_mode`, `_easydiffraction_experiment_type.radiation_probe`, `_easydiffraction_experiment_type.scattering_type`, `_easydiffraction_calculator.type`, `_easydiffraction_peak.type`, `_easydiffraction_background.type`, `_easydiffraction_sc_crystal_block.id`, `_easydiffraction_sc_crystal_block.scale`, `_easydiffraction_diffrn.ambient_magnetic_field`, `_easydiffraction_diffrn.ambient_electric_field`, `_easydiffraction_extinction.type`, `_easydiffraction_extinction.model`, `_easydiffraction_extinction.mosaicity`, `_easydiffraction_extinction.radius` | Preserves EasyDiffraction-specific state that has no exact coreCIF/pdCIF equivalent but is needed to trace how the reported fit was configured. | ### 6. Shared `ReportDataContext` + Jinja templates @@ -1791,14 +1743,6 @@ def data_context(self) -> dict: 'constraints': ..., }, 'software': {...}, # from §4 - 'publication': { # from project.publication (§5); unset fields → None - 'journal': {...}, # _journal.* - 'journal_date': {...}, # _journal_date.* (editor-side) - 'journal_coeditor': {...}, # _journal_coeditor.* (editor-side) - 'contact_author': {...}, # _publ_contact_author.* - 'body': {...}, # _publ_body.{title, synopsis, abstract, keywords} - 'authors': [...], # _publ_author.* loop - }, 'metadata': { 'easydiffraction_version': ..., 'generated_at': ..., @@ -1955,8 +1899,8 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously. `_report.html_offline`). Set the configuration once through those booleans; `project.save()` applies it on every save thereafter. Replaces the flag with persisted configuration, matching the - existing `project.chart`, `project.table`, `project.verbosity` - pattern. + existing `project.rendering_plot`, `project.rendering_table`, + `project.verbosity` pattern. 2. **`project.report.save()` surface redesigned.** The accepted `project.report.save()` is now a no-argument convenience that reads the configuration category (raises `ValueError` when no formats are @@ -1990,25 +1934,15 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously. existing `_audit.creation_date` keeps its `_iso_creation_datetime()` source (report-generation time) and is **not** overwritten — fit time and report time are distinct events. - 5. **Publication metadata in the default save.** The alignment ADR's - Scope explicitly excluded "Adding new CIF categories the project - does not currently track (`_chemical.*`, `_publ.*`, `_journal.*`) - **for the default save**" (alignment ADR §Scope, lines 101-110). - This ADR adds `project.publication.*` (§5) and persists it to - `project.cif` — a different file from `reports/.cif`, but - still a default-save change that the alignment ADR did not - anticipate. Specifically: - - `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`, - `_journal.*`, `_journal_date.*`, `_journal_coeditor.*` are now in - scope for `project.cif`. - - The accepted IUCr export still reads these from - `project.publication.*` and emits them in `data_global` per - §2.3a; the `?` placeholder semantics for unset fields are - unchanged. - - The accepted "Export only — no round-trip" rule for - `reports/.cif` is **unaffected** — `project.publication` - round-trips through `project.cif`, not through the report CIF - (see §5.1 / §5.3). + 5. **Publication metadata excluded from the default save and report + CIF.** The alignment ADR's Scope explicitly excluded "Adding new + CIF categories the project does not currently track (`_chemical.*`, + `_publ.*`, `_journal.*`) **for the default save**" (alignment ADR + §Scope, lines 101-110). This ADR keeps that exclusion for `_publ_*` + and `_journal_*`: no `project.publication` owner is added, no + journal/publication metadata is persisted to `project.cif`, and the + report CIF omits the empty publication and powder-measurement + author placeholders listed in §5.1. All other IUCr-export decisions in the alignment ADR (multi-datablock layout, tag-name policy, gemmi as the validation engine) are @@ -2021,7 +1955,7 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously. - [`analysis-cif-fit-state.md`](../accepted/analysis-cif-fit-state.md) — adds `analysis.software` to the persisted analysis state. - [`project-facade-and-persistence.md`](../accepted/project-facade-and-persistence.md) - — three changes: + — three points: 1. **`project.report` gains a persisted configuration category.** The accepted ADR scoped `project.report` as a CIF-write helper (single output: `reports/.cif`). This ADR extends it with a @@ -2030,34 +1964,25 @@ every renderer (HTML, PDF, terminal, GUI) simultaneously. every save. The facade becomes a hybrid — helper methods (`save_*()`) **and** persisted configuration on the same Python object. - 2. **New top-level `project.publication` facade slot (§5).** Sibling - to `project.info`, `project.structures`, `project.experiments`, - `project.analysis`, `project.report`. Persisted to `project.cif` - next to the other project-level singleton categories. - 3. **Project-level singleton category enumeration extended.** The - accepted ADR enumerates `_info.*`, `_chart.*`, `_table.*`, - `_verbosity.*` as the project-level singleton categories owned by - `project.cif`. This ADR adds two more to that enumeration: - `_report.*` (this ADR §1.3) and `_publication.*` family (this ADR - §5; concrete sub-prefixes are `_publ_*` and `_journal_*` per IUCr - coreCIF). + 2. **No top-level `project.publication` facade slot in v1 (§5).** The + previously considered journal/publication metadata surface is + deferred so `project.cif` stays free of empty manuscript and + journal-administration fields. + 3. **Project-level singleton category enumeration extended only by + `_report.*`.** The accepted ADR enumerates `_info.*`, + `_rendering_plot.*`, `_rendering_table.*`, `_verbosity.*` as the + project-level singleton categories owned by `project.cif`. This ADR + adds `_report.*` (this ADR §1.3) and explicitly does not add + `_publ_*`, `_journal_*`, or any `_publication.*` family. - [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) - — owns the Python-to-CIF correspondence rule for two new project-level - singleton surfaces: + — owns the Python-to-CIF correspondence rule for one new project-level + singleton surface: - `project.report.*` ↔ `_report.*` — five scalar items (four format booleans plus `html_offline`) per §1.3. - - `project.publication.*` ↔ `_publ_*` / `_journal_*` sibling - categories per §5. Python attributes are lowercase snake_case - (`id_orcid`); CIF tags retain dictionary casing - (`_publ_contact_author.id_ORCID`). - - Both follow the correspondence ADR's existing 1:1 mapping pattern. The - correspondence ADR's enumeration of "currently persisted Python - category surfaces" gains two rows for these additions. - No conflict with the correspondence ADR's project-level category list - because `_publ_*` / `_journal_*` are publication-domain, not - project-level singleton categories. + The rejected `project.publication.*` ↔ `_publ_*` / `_journal_*` + mapping is deferred in §5 and must not be added to the current + project-level category list. ## Open Questions @@ -2158,12 +2083,10 @@ browser anyway. Deferred. matplotlib-rendered PDF figures for the LaTeX path while keeping pgfplots as the default. Tune when needed. - Tab/accordion navigation in HTML for projects with many experiments. -- `project.report.check_completeness()` — complements the internal gemmi - pass from §1.4 (dictionary spec compliance, enforced before every CIF - write). The completeness check would flag whether the user has filled - in `_publ_*` / `_journal_*` placeholders for their target journal, - which dictionary validation cannot determine. Different concern, - different layer. +- A future journal-submission completeness check — complements the + internal gemmi pass from §1.4 (dictionary spec compliance, enforced + before every CIF write). It would belong with a future + journal-metadata surface, not with the v1 clean report CIF. - A pinned-snapshot variant of `.html` (timestamped, kept next to fit-run-specific artifacts) for users who want to track refinement history visually across saves. @@ -2180,7 +2103,7 @@ browser anyway. Deferred. **Description:** Builds on the IUCr CIF alignment work by filling in the non-CIF half of -the publication bundle. The `project.report` facade covers four output +the report bundle. The `project.report` facade covers four output formats — CIF, HTML, TeX, PDF — chosen via a configuration category on the project (persisted in `project.cif`) and applied automatically on every save: diff --git a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md b/docs/dev/adrs/accepted/python-cif-category-correspondence.md similarity index 61% rename from docs/dev/adrs/suggestions/python-cif-category-correspondence.md rename to docs/dev/adrs/accepted/python-cif-category-correspondence.md index f7fbe77c5..a9963270e 100644 --- a/docs/dev/adrs/suggestions/python-cif-category-correspondence.md +++ b/docs/dev/adrs/accepted/python-cif-category-correspondence.md @@ -1,6 +1,6 @@ # ADR: Python and CIF Category Correspondence -**Status:** Proposed +**Status:** Accepted **Date:** 2026-05-17 ## Context @@ -16,16 +16,19 @@ saved in: project.cif ``` -Inside that file, generic category names such as `_info.*`, `_chart.*`, -`_table.*`, and `_verbosity.*` are less ambiguous than they would be in -a single monolithic CIF file. This opens the option of a strict -one-to-one correspondence for project-owned singleton categories: +Inside that file, project-owned category names such as +`_rendering_plot.*`, `_report.*`, `_structure_view.*`, +`_structure_style.*`, and `_verbosity.*` are less ambiguous than they +would be in a single monolithic CIF file. This opens the option of a +scoped one-to-one correspondence for EasyDiffraction-owned singleton +configuration categories: ```text -project.info.title -> project.cif: _info.title -project.chart.type -> project.cif: _chart.type -project.table.type -> project.cif: _table.type -project.verbosity.fit -> project.cif: _verbosity.fit +project.rendering_plot.type -> project.cif: _rendering_plot.type +project.report.cif -> project.cif: _report.cif +project.structure_view.range_a_min -> project.cif: _structure_view.range_a_min +project.structure_style.atom_view -> project.cif: _structure_style.atom_view +project.verbosity.fit -> project.cif: _verbosity.fit ``` The design question is whether this rule should be applied only to @@ -36,8 +39,16 @@ The accepted project-facade decision keeps `Project` as the public root and keeps `project.cif` as the singleton project configuration file. It also keeps `_project.*` as the semantic CIF category for scientific project information and rejects `_meta.*` for that purpose. This ADR -therefore must not reintroduce the rejected `Workspace` rename, -`workspace.cif`, or `_meta.project_*` tags as incidental cleanup. +therefore does **not** reintroduce the rejected `Workspace` rename, +`workspace.cif`, `_meta.project_*` tags, or a broad `_info.*` rewrite as +incidental cleanup. + +The accepted project-summary-rendering ADR also rejected a v1 +`project.publication` owner. Journal, author, publication-body, and +powder-measurement author metadata are not represented in code, are not +persisted in `project.cif`, and are not emitted as empty report-CIF +placeholders. This ADR records that as an intentional correspondence +gap, not as a missing mapping. ## Scope Of Comparison @@ -53,55 +64,69 @@ to objects reached from the current `Project` root, for example ## Current Persistence Layout -| Current Python surface | Current saved location | Current CIF block form | Notes | -| ------------------------------------------------ | ------------------------ | ---------------------- | ------------------------------------------------------------------------------------- | -| `project.info`, `project.chart`, `project.table` | `project.cif` | bare categories | Project-level singleton config. | -| `project.report` | `project.cif` | bare category | Project-owned report-output config; report methods render artifacts under `reports/`. | -| `project.publication` | `project.cif` | bare categories + loop | Journal-submission metadata under `_journal_*` / `_publ_*` categories. | -| `project.verbosity` | `project.cif` | bare category | Project-owned fit-output verbosity category backed by `VerbosityEnum`. | -| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. | -| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. | -| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. | -| `project.summary` | `summary.cif` | placeholder text | Summary persistence exists as a file but `summary_to_cif()` is not implemented yet. | +| Current Python surface | Current saved location | Current CIF block form | Notes | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------- | +| `project.info`, `project.rendering_plot`, `project.report`, `project.rendering_table`, `project.rendering_structure`, `project.structure_view`, `project.structure_style`, `project.verbosity` | `project.cif` | bare categories | Project-level singleton config. | +| `project.structures[name]` | `structures/.cif` | `data_` | Each structure is one CIF data block. | +| `project.experiments[name]` | `experiments/.cif` | `data_` | Each experiment is one CIF data block. | +| `project.analysis` | `analysis/analysis.cif` | bare categories | Loader also accepts legacy root-level `analysis.cif`. | +| `project.report.save_*()` / `project.report.{cif,html,tex,pdf}` | `reports/` | multi-datablock CIF or rendered artifacts | Derived report outputs; generated only when configured or called explicitly. | ## Current Correspondence ### Project-Level Configuration -| Current Python path | Current CIF path | Match? | Notes | -| ---------------------------- | ------------------------ | ------ | -------------------------------------------------------------------------------------------------- | -| `project.info.name` | `_project.id` | No | Python uses user-facing `name`; CIF uses `id`; category is `info` in Python but `_project` in CIF. | -| `project.info.title` | `_project.title` | Partly | Field name matches, category name does not. | -| `project.info.description` | `_project.description` | Partly | Field name matches, category name does not. | -| `project.info.created` | `_project.created` | Partly | Field name matches, category name does not. | -| `project.info.last_modified` | `_project.last_modified` | Partly | Field name matches, category name does not. | -| `project.info.path` | none | No | Runtime storage path, not a CIF field. | -| `project.chart.type` | `_chart.type` | Yes | Direct category-owned selector mapping. | -| `project.report.*` | `_report.*` | Yes | Direct project-owned report-output configuration mapping. | -| `project.publication.*` | `_journal.*` / `_publ_*` | Partly | Python keeps one owner with sibling categories; CIF uses journal and publication dictionary names. | -| `project.table.type` | `_table.type` | Yes | Direct category-owned selector mapping. | -| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. | +| Current Python path | Current CIF path | Match? | Notes | +| ------------------------------------------------ | ----------------------------------------- | ------ | ----------------------------------------------------------------------------------- | +| `project.info.name` | `_project.id` | No | Accepted exception: Python uses user-facing `name`; CIF uses semantic project `id`. | +| `project.info.title` | `_project.title` | Partly | Accepted exception: field name matches, category name is semantic `_project`. | +| `project.info.description` | `_project.description` | Partly | Accepted exception: field name matches, category name is semantic `_project`. | +| `project.info.created` | `_project.created` | Partly | Accepted exception: field name matches, category name is semantic `_project`. | +| `project.info.last_modified` | `_project.last_modified` | Partly | Accepted exception: field name matches, category name is semantic `_project`. | +| `project.info.path` | none | No | Runtime storage path, not a CIF field. | +| `project.rendering_plot.type` | `_rendering_plot.type` | Yes | Direct category-owned selector mapping. | +| `project.report.cif` | `_report.cif` | Yes | Direct project-owned report-output configuration mapping. | +| `project.report.html` | `_report.html` | Yes | Direct project-owned report-output configuration mapping. | +| `project.report.tex` | `_report.tex` | Yes | Direct project-owned report-output configuration mapping. | +| `project.report.pdf` | `_report.pdf` | Yes | Direct project-owned report-output configuration mapping. | +| `project.report.html_offline` | `_report.html_offline` | Yes | Direct project-owned report-output configuration mapping. | +| `project.rendering_table.type` | `_rendering_table.type` | Yes | Direct category-owned selector mapping. | +| `project.rendering_structure.type` | `_rendering_structure.type` | Yes | Direct category-owned selector mapping. | +| `project.structure_view.show_labels` | `_structure_view.show_labels` | Yes | Direct project-owned structure-view state mapping. | +| `project.structure_view.show_moments` | `_structure_view.show_moments` | Yes | Direct project-owned structure-view state mapping. | +| `project.structure_view.range_{a,b,c}_{min,max}` | `_structure_view.range_{a,b,c}_{min,max}` | Yes | Six scalar bounds; direct project-owned structure-view state mapping. | +| `project.structure_style.atom_view` | `_structure_style.atom_view` | Yes | Direct project-owned structure-style value selector mapping. | +| `project.structure_style.color_scheme` | `_structure_style.color_scheme` | Yes | Direct project-owned structure-style value selector mapping. | +| `project.structure_style.adp_probability` | `_structure_style.adp_probability` | Yes | Direct project-owned structure-style numeric setting. | +| `project.structure_style.atom_scale` | `_structure_style.atom_scale` | Yes | Direct project-owned structure-style numeric setting. | +| `project.verbosity.fit` | `_verbosity.fit` | Yes | Direct category and field mapping for fitting process output verbosity. | +| `project.verbosity = 'short'` | `_verbosity.fit` | Alias | Convenience setter only; canonical persisted path remains `project.verbosity.fit`. | ### Analysis Configuration -| Current Python path | Current CIF path | Match? | Notes | -| ------------------------------------------------- | ---------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -| `analysis.minimizer.type` | `_minimizer.type` | Yes | Direct category-owned selector mapping. | -| `analysis.fitting_mode.type` | `_fitting_mode.type` | Yes | Direct category-owned active-sibling selector mapping. | -| `analysis.joint_fit[experiment_id].experiment_id` | `_joint_fit.experiment_id` | Yes | Collection key is also stored as a field. | -| `analysis.joint_fit[experiment_id].weight` | `_joint_fit.weight` | Yes | Direct field mapping. | -| `analysis.sequential_fit.data_dir` | `_sequential_fit.data_dir` | Yes | Direct category mapping. | -| `analysis.sequential_fit.file_pattern` | `_sequential_fit.file_pattern` | Yes | Direct category mapping. | -| `analysis.sequential_fit.max_workers` | `_sequential_fit.max_workers` | Yes | Direct category mapping. | -| `analysis.sequential_fit.chunk_size` | `_sequential_fit.chunk_size` | Yes | Direct category mapping. | -| `analysis.sequential_fit.reverse` | `_sequential_fit.reverse` | Yes | Direct category mapping. | -| `analysis.sequential_fit_extract[id].id` | `_sequential_fit_extract.id` | Yes | Direct collection mapping. | -| `analysis.sequential_fit_extract[id].target` | `_sequential_fit_extract.target` | Yes | Direct collection mapping. | -| `analysis.sequential_fit_extract[id].pattern` | `_sequential_fit_extract.pattern` | Yes | Direct collection mapping. | -| `analysis.sequential_fit_extract[id].required` | `_sequential_fit_extract.required` | Yes | Direct collection mapping. | -| `analysis.aliases[label].label` | `_alias.label` | Partly | Python collection is plural; CIF row category is singular. | -| `analysis.aliases[label].param_unique_name` | `_alias.param_unique_name` | Partly | Python collection is plural; CIF row category is singular. | -| `analysis.constraints[lhs_alias].expression` | `_constraint.expression` | Partly | Collection key is derived from the expression; there is no separate `_constraint.lhs_alias` tag. | +| Current Python path | Current CIF path | Match? | Notes | +| ------------------------------------------------- | ---------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | +| `analysis.minimizer.type` | `_minimizer.type` | Yes | Direct category-owned selector mapping. | +| `analysis.fitting_mode.type` | `_fitting_mode.type` | Yes | Direct category-owned active-sibling selector mapping. | +| `analysis.fit_result.*` | `_fit_result.*` | Yes | Direct category mapping for scalar fit-result state; IUCr report export may use transformed tags. | +| `analysis.fit_parameters[param].*` | `_fit_parameter.*` | Yes | Direct loop mapping for persisted per-parameter fit state. | +| `analysis.fit_parameter_correlations[id].*` | `_fit_parameter_correlation.*` | Yes | Direct loop mapping for deterministic and posterior correlation summaries. | +| `analysis.joint_fit[experiment_id].experiment_id` | `_joint_fit.experiment_id` | Yes | Collection key is also stored as a field. | +| `analysis.joint_fit[experiment_id].weight` | `_joint_fit.weight` | Yes | Direct field mapping. | +| `analysis.software.*` | `_software.*` | Yes | Direct analysis-tier software provenance mapping stamped at fit time. | +| `analysis.sequential_fit.data_dir` | `_sequential_fit.data_dir` | Yes | Direct category mapping. | +| `analysis.sequential_fit.file_pattern` | `_sequential_fit.file_pattern` | Yes | Direct category mapping. | +| `analysis.sequential_fit.max_workers` | `_sequential_fit.max_workers` | Yes | Direct category mapping. | +| `analysis.sequential_fit.chunk_size` | `_sequential_fit.chunk_size` | Yes | Direct category mapping. | +| `analysis.sequential_fit.reverse` | `_sequential_fit.reverse` | Yes | Direct category mapping. | +| `analysis.sequential_fit_extract[id].id` | `_sequential_fit_extract.id` | Yes | Direct collection mapping. | +| `analysis.sequential_fit_extract[id].target` | `_sequential_fit_extract.target` | Yes | Direct collection mapping. | +| `analysis.sequential_fit_extract[id].pattern` | `_sequential_fit_extract.pattern` | Yes | Direct collection mapping. | +| `analysis.sequential_fit_extract[id].required` | `_sequential_fit_extract.required` | Yes | Direct collection mapping. | +| `analysis.aliases[label].label` | `_alias.label` | Partly | Python collection is plural; CIF row category is singular. | +| `analysis.aliases[label].param_unique_name` | `_alias.param_unique_name` | Partly | Python collection is plural; CIF row category is singular. | +| `analysis.constraints[id].id` | `_constraint.id` | Yes | Direct explicit row-key mapping; older CIFs may backfill the id from the expression left-hand side. | +| `analysis.constraints[id].expression` | `_constraint.expression` | Yes | Direct row-field mapping; `lhs_alias` and `rhs_expr` are derived Python helpers. | ### Experiment Configuration @@ -184,85 +209,85 @@ to objects reached from the current `Project` root, for example ### Structure Configuration -| Current Python path | Current CIF path | Match? | Notes | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- | -| `structure.cell.length_a` | `_cell.length_a` | Yes | Direct category mapping. | -| `structure.cell.length_b` | `_cell.length_b` | Yes | Direct category mapping. | -| `structure.cell.length_c` | `_cell.length_c` | Yes | Direct category mapping. | -| `structure.cell.angle_alpha` | `_cell.angle_alpha` | Yes | Direct category mapping. | -| `structure.cell.angle_beta` | `_cell.angle_beta` | Yes | Direct category mapping. | -| `structure.cell.angle_gamma` | `_cell.angle_gamma` | Yes | Direct category mapping. | -| `structure.space_group.name_h_m` | `_space_group.name_H-M_alt`, `_space_group_name_H-M_alt`, `_symmetry.space_group_name_H-M`, or `_symmetry_space_group_name_H-M` | Partly | CIF naming follows crystallographic conventions and supports legacy alternatives. | -| `structure.space_group.it_coordinate_system_code` | `_space_group.IT_coordinate_system_code`, `_space_group_IT_coordinate_system_code`, `_symmetry.IT_coordinate_system_code`, or `_symmetry_IT_coordinate_system_code` | Partly | CIF naming follows crystallographic conventions and supports legacy alternatives. | -| `structure.atom_sites[label].label` | `_atom_site.label` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].type_symbol` | `_atom_site.type_symbol` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].fract_x` | `_atom_site.fract_x` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].fract_y` | `_atom_site.fract_y` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].fract_z` | `_atom_site.fract_z` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].wyckoff_letter` | `_atom_site.Wyckoff_letter` or `_atom_site.Wyckoff_symbol` | Partly | CIF uses capitalized/legacy Wyckoff tags. | -| `structure.atom_sites[label].occupancy` | `_atom_site.occupancy` | Yes | Direct row-field mapping. | -| `structure.atom_sites[label].adp_iso` | `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_sites[label].adp_type` | `_atom_site.adp_type` | Yes | Direct row-field mapping. | -| `structure.atom_site_aniso[label].label` | `_atom_site_aniso.label` | Yes | Direct row-field mapping. | -| `structure.atom_site_aniso[label].adp_11` | `_atom_site_aniso.B_11` or `_atom_site_aniso.U_11` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_site_aniso[label].adp_22` | `_atom_site_aniso.B_22` or `_atom_site_aniso.U_22` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_site_aniso[label].adp_33` | `_atom_site_aniso.B_33` or `_atom_site_aniso.U_33` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_site_aniso[label].adp_12` | `_atom_site_aniso.B_12` or `_atom_site_aniso.U_12` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_site_aniso[label].adp_13` | `_atom_site_aniso.B_13` or `_atom_site_aniso.U_13` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | -| `structure.atom_site_aniso[label].adp_23` | `_atom_site_aniso.B_23` or `_atom_site_aniso.U_23` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | - -### Not Yet Mapped - -| Current Python path | Current CIF status | Notes | -| ------------------- | --------------------- | -------------------------------------------- | -| `project.summary` | placeholder text only | `summary_to_cif()` currently returns a stub. | - -## Decision To Discuss - -Adopt a scoped one-to-one rule for project-level configuration: +| Current Python path | Current CIF path | Match? | Notes | +| ------------------------------------------------- | ---------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `structure.cell.length_a` | `_cell.length_a` | Yes | Direct category mapping. | +| `structure.cell.length_b` | `_cell.length_b` | Yes | Direct category mapping. | +| `structure.cell.length_c` | `_cell.length_c` | Yes | Direct category mapping. | +| `structure.cell.angle_alpha` | `_cell.angle_alpha` | Yes | Direct category mapping. | +| `structure.cell.angle_beta` | `_cell.angle_beta` | Yes | Direct category mapping. | +| `structure.cell.angle_gamma` | `_cell.angle_gamma` | Yes | Direct category mapping. | +| `structure.space_group.name_h_m` | `_space_group.name_H-M_alt` | Partly | Default write uses dictionary-canonical casing; legacy `_space_group_name_H-M_alt` and `_symmetry*` alternatives are accepted on read. | +| `structure.space_group.it_coordinate_system_code` | `_space_group.IT_coordinate_system_code` | Partly | Default write uses dictionary-canonical casing; legacy underscore-form and `_symmetry*` alternatives are accepted on read. | +| `structure.atom_sites[label].label` | `_atom_site.label` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].type_symbol` | `_atom_site.type_symbol` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].fract_x` | `_atom_site.fract_x` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].fract_y` | `_atom_site.fract_y` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].fract_z` | `_atom_site.fract_z` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].wyckoff_letter` | `_atom_site.Wyckoff_symbol` | Partly | Default write uses dictionary-canonical tag; legacy `_atom_site.Wyckoff_letter` is accepted on read. | +| `structure.atom_sites[label].occupancy` | `_atom_site.occupancy` | Yes | Direct row-field mapping. | +| `structure.atom_sites[label].adp_iso` | `_atom_site.B_iso_or_equiv` or `_atom_site.U_iso_or_equiv` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_sites[label].adp_type` | `_atom_site.ADP_type` | Partly | Default write uses dictionary-canonical capitalization; legacy `_atom_site.adp_type` is accepted on read. | +| `structure.atom_site_aniso[label].label` | `_atom_site_aniso.label` | Yes | Direct row-field mapping. | +| `structure.atom_site_aniso[label].adp_11` | `_atom_site_aniso.B_11` or `_atom_site_aniso.U_11` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_site_aniso[label].adp_22` | `_atom_site_aniso.B_22` or `_atom_site_aniso.U_22` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_site_aniso[label].adp_33` | `_atom_site_aniso.B_33` or `_atom_site_aniso.U_33` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_site_aniso[label].adp_12` | `_atom_site_aniso.B_12` or `_atom_site_aniso.U_12` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_site_aniso[label].adp_13` | `_atom_site_aniso.B_13` or `_atom_site_aniso.U_13` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | +| `structure.atom_site_aniso[label].adp_23` | `_atom_site_aniso.B_23` or `_atom_site_aniso.U_23` | No | Python uses type-neutral ADP name; CIF uses B/U-specific tags. | + +### Not Represented In V1 + +| Candidate surface | Current CIF status | Notes | +| ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `project.summary` | removed | Replaced by `project.report`; no `summary.cif` placeholder is written. | +| `project.publication` | none | Rejected for v1 by the accepted project-summary-rendering ADR. | +| journal/publication tags | not emitted | `_journal.*`, `_journal_date.*`, `_journal_coeditor.*`, `_publ_contact_author.*`, `_publ_author.*`, `_publ_body.*`, and `_pd_meas.info_author_*` placeholders are deferred and intentionally omitted while empty. | + +## Decision + +Adopt a scoped one-to-one rule for EasyDiffraction-owned project-level +singleton configuration: ```text project.. -> project.cif: _. ``` -This ADR does not propose renaming the public root object. The current -root object is already `Project`; the proposal is about category and tag -correspondence inside project-owned singleton configuration. +The rule applies to: + +- `project.rendering_plot.type -> _rendering_plot.type` +- `project.report.{cif,html,tex,pdf,html_offline} -> _report.*` +- `project.rendering_table.type -> _rendering_table.type` +- `project.rendering_structure.type -> _rendering_structure.type` +- `project.structure_view.* -> _structure_view.*` +- `project.structure_style.* -> _structure_style.*` +- `project.verbosity.fit -> _verbosity.fit` -The accepted baseline is: +Keep `project.info` as the accepted exception: ```text -project.info. -> project.cif: _project. +project.info.name -> _project.id +project.info.title -> _project.title +project.info.description -> _project.description +project.info.created -> _project.created +project.info.last_modified -> _project.last_modified ``` -Future one-to-one correspondence work may still discuss whether the -public identity field should be `name` or `id`, and whether verbosity -should gain additional coverage-specific fields. - -Possible strict-correspondence target if a future ADR explicitly changes -the accepted `_project.*` baseline: +The exception is deliberate. `_project.*` is the semantic CIF category +for scientific project identity in this project file, and `name` remains +the user-facing Python property. This ADR does not rename `name` to +`id`, does not rename `_project.*` to `_info.*`, and does not add an +`_info.*` compatibility layer. -| Python path | Target CIF path | Current state | -| ---------------------------- | --------------------- | ------------------------------------------------ | -| `project.info.name` | `_info.name` | Currently `_project.id`. | -| `project.info.title` | `_info.title` | Currently `_project.title`. | -| `project.info.description` | `_info.description` | Currently `_project.description`. | -| `project.info.created` | `_info.created` | Currently `_project.created`. | -| `project.info.last_modified` | `_info.last_modified` | Currently `_project.last_modified`. | -| `project.chart.type` | `_chart.type` | Already matches. | -| `project.table.type` | `_table.type` | Already matches. | -| `project.verbosity.fit` | `_verbosity.fit` | Implemented direct fit-output verbosity mapping. | +Do not force strict one-to-one correspondence globally. Analysis, +experiment, structure, measured-data, calculated-data, and report-export +categories may keep CIF-domain names where those names are clearer, +dictionary-aligned, or intentionally different from Python convenience +names. -Alternative target if the project identity field should be called `id` -rather than `name`: - -```text -project.info.id -> _info.id -``` - -Do not force strict one-to-one correspondence globally where CIF-domain -names are clearer or where the Python API intentionally abstracts over -CIF details. +Do not add a v1 `project.publication` owner or empty publication tags to +make the correspondence table appear complete. Publication metadata is a +future feature with its own sourcing and completeness questions. ## Rationale @@ -272,12 +297,13 @@ Project-level configuration categories are not external crystallographic CIF categories. They are EasyDiffraction project-file categories, so the repository can optimize them for API/persistence symmetry. -### `project.cif` Scopes Generic Categories +### `project.cif` Scopes Project-Owned Categories -`_info.title` is generic in isolation, but inside `project.cif` it reads -as project information. This is similar to `_verbosity.fit`: the file -scope tells the reader this is project-level verbosity, and the field -name identifies the fitting-process coverage. +`_report.cif`, `_structure_view.show_labels`, and `_verbosity.fit` are +generic in isolation, but inside `project.cif` they read as +project-level report, structure-view, and verbosity configuration. The +file scope supplies the project root; the category and field names +identify the specific setting. ### The Current `Project` Root Already Matches User Language @@ -294,6 +320,14 @@ say that directly, while `_meta.project_id` and `_meta.project_title` make the CIF less domain-oriented and repeat the concept in every item name. +### `project.info` Is A Deliberate Exception + +`project.info` is the user-facing Python grouping, but `_project.*` is +the accepted CIF grouping for project identity. Preserving this +exception avoids a beta-period churn-only rename from `name` to `id` in +Python and avoids a persistence migration from `_project.*` to `_info.*` +without a scientific benefit. + ### Scientific CIF/Domain Categories Should Stay Domain-Oriented For structures, experiments, measured data, and calculated results, many @@ -312,6 +346,13 @@ switchable-category, backend, and active-sibling selector families. These should remain exceptions unless a separate ADR changes the underlying API pattern. +### Convenience Aliases Do Not Define Persistence + +`project.verbosity = 'short'` remains acceptable as a user-facing +shortcut because it writes the canonical `project.verbosity.fit` value. +The persistence contract is still the category/field path, not every +convenience setter that happens to reach it. + ## Consequences ### Positive @@ -320,25 +361,22 @@ underlying API pattern. - Users can predict project-level CIF tags from Python paths. - The decision can focus on project-owned singleton config without forcing scientific CIF categories to mirror Python convenience names. +- The clean-report decision remains intact: empty journal/publication + placeholders stay out of both `project.cif` and generated report CIFs. ### Trade-Offs -- `_info.*` is less self-describing if copied out of `project.cif`. -- Existing `_project.*` project files would need migration or a - deliberate compatibility decision. -- Persisted verbosity is now a category object. The initial field is +- `project.info` does not follow the strict category-name rule; this + exception must be explained alongside the other project config. +- Future publication metadata needs a separate ADR rather than a quiet + extension of this correspondence table. +- Persisted verbosity remains a category object. The initial field is `project.verbosity.fit`, leaving room for future coverage-specific verbosity fields. - Chart and table renderers are separate selector categories - (`project.chart.type`, `project.table.type`), so a future collapsed - renderer setting would need a separate ADR. + (`project.rendering_plot.type`, `project.rendering_table.type`), so a + future collapsed renderer setting would need a separate ADR. ## Open Questions -- Should the project identity remain `project.info.name`, or should it - become `project.info.id` to mirror the saved identifier field? -- Should `project.chart.type` and `project.table.type` remain separate, - or should the public API and CIF collapse to one renderer field? -- Should `project.verbosity = 'short'` remain as a convenience alias for - `project.verbosity.fit = 'short'`, or should strict correspondence - remove the alias? +None for this ADR. diff --git a/docs/dev/adrs/accepted/selector-families.md b/docs/dev/adrs/accepted/selector-families.md index 66fb655ff..1d84f0a33 100644 --- a/docs/dev/adrs/accepted/selector-families.md +++ b/docs/dev/adrs/accepted/selector-families.md @@ -38,11 +38,11 @@ or activates sibling categories. Recognize three selector families: -| Family | User intent | Examples | -| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------- | -| Backend selector | Pick an execution backend | `experiment.calculator.type`, `project.chart.type`, `project.table.type` | -| Switchable-category selector | Swap a category implementation | `analysis.minimizer.type`, `experiment.background.type`, `experiment.peak.type` | -| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode.type` | +| Family | User intent | Examples | +| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- | +| Backend selector | Pick an execution backend | `experiment.calculator.type`, `project.rendering_plot.type`, `project.rendering_table.type` | +| Switchable-category selector | Swap a category implementation | `analysis.minimizer.type`, `experiment.background.type`, `experiment.peak.type` | +| Active-sibling selector | Pick the active sibling surface | `analysis.fitting_mode.type` | Backend selectors live on dedicated configuration categories. Switchable-category selectors live on the category they replace, and the diff --git a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md index 9207992e5..2d3ca926a 100644 --- a/docs/dev/adrs/accepted/switchable-category-owned-selectors.md +++ b/docs/dev/adrs/accepted/switchable-category-owned-selectors.md @@ -52,7 +52,7 @@ Three problems have accumulated since that ADR landed: swapped category writes; `_calculation.calculator_type` lives inside its category block but the descriptor name awkwardly repeats the noun ("calculator") instead of using a uniform `.type` selector. - [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) + [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) notes the inconsistency under §"Owner-level switchable selectors" and tags it for a future ADR. @@ -140,16 +140,16 @@ the `Calculation` category is renamed (see §8); one is new (`_fitting_mode`) because the active-sibling selector is promoted to its own category (also §8). -| Today | Replacement | Mechanism family ¹ | -| ------------------------------------- | -------------------- | ------------------ | -| `_fitting.minimizer_type` | `_minimizer.type` | A | -| `_peak.profile_type` | `_peak.type` | A | -| (none — only `_pd_background.*` loop) | `_background.type` | A | -| (none — only active-class fields) | `_extinction.type` | A | -| `_calculation.calculator_type` | `_calculator.type` | B (and §8 rename) | -| `_rendering.chart_engine` | `_chart.type` | B (and §8 split) | -| `_rendering.table_engine` | `_table.type` | B (and §8 split) | -| `_fitting.mode_type` | `_fitting_mode.type` | C (and §8 promote) | +| Today | Replacement | Mechanism family ¹ | +| ------------------------------------- | ----------------------- | ------------------ | +| `_fitting.minimizer_type` | `_minimizer.type` | A | +| `_peak.profile_type` | `_peak.type` | A | +| (none — only `_pd_background.*` loop) | `_background.type` | A | +| (none — only active-class fields) | `_extinction.type` | A | +| `_calculation.calculator_type` | `_calculator.type` | B (and §8 rename) | +| `_rendering.chart_engine` | `_rendering_plot.type` | B (and §8 split) | +| `_rendering.table_engine` | `_rendering_table.type` | B (and §8 split) | +| `_fitting.mode_type` | `_fitting_mode.type` | C (and §8 promote) | ¹ Mechanism family per [`selector-families.md`](selector-families.md): A swaps the category instance, B swaps the live engine behind a singleton @@ -461,11 +461,11 @@ Owners contribute only the filter dict via `_supported_filters_for(category)`. The Family-B swap hooks (e.g. `Experiment._swap_calculator`, -`Project._swap_chart`, `Project._swap_table`) follow the same shape but -rebind the live engine rather than the category instance. The Family-C -swap hook (`Analysis._swap_fitting_mode`) performs the existing -sibling-activation logic. The mixin does not care which mechanism the -owner uses; it only routes the writable surface. +`Project._swap_rendering_plot`, `Project._swap_rendering_table`) follow +the same shape but rebind the live engine rather than the category +instance. The Family-C swap hook (`Analysis._swap_fitting_mode`) +performs the existing sibling-activation logic. The mixin does not care +which mechanism the owner uses; it only routes the writable surface. CIF read path becomes: @@ -599,12 +599,13 @@ levels). The `Rendering` category is **removed**. Two new sibling categories appear on `Project`: -- `project.chart` — `CategoryItem` with one writable selector `type` - (`PlotterEngineEnum` plus the `'auto'` sentinel) and the live - `Plotter` facade as a private internal. CIF block: `_chart.*`. -- `project.table` — `CategoryItem` with one writable selector `type` - (`TableEngineEnum` plus `'auto'`) and the live `TableRenderer` facade - as a private internal. CIF block: `_table.*`. +- `project.rendering_plot` — `CategoryItem` with one writable selector + `type` (`PlotterEngineEnum` plus the `'auto'` sentinel) and the live + `Plotter` facade as a private internal. CIF block: + `_rendering_plot.*`. +- `project.rendering_table` — `CategoryItem` with one writable selector + `type` (`TableEngineEnum` plus `'auto'`) and the live `TableRenderer` + facade as a private internal. CIF block: `_rendering_table.*`. Both follow the §4 mechanism — Family B (engine swap), `category.type` surface — and become natural homes for future chart-only and table-only @@ -614,8 +615,8 @@ descriptors (e.g. `chart.height`, `chart.theme`, `table.max_rows`, The owner-level `project.rendering.show_chart_engines()`, `project.rendering.show_table_engines()`, and `project.rendering.show_config()` methods are deleted. Their -replacements are `project.chart.show_supported()`, -`project.table.show_supported()`, and (if needed) a thin +replacements are `project.rendering_plot.show_supported()`, +`project.rendering_table.show_supported()`, and (if needed) a thin `project.show_config()` that prints both categories' current state. #### 8b. `analysis.fitting_mode_type` → `analysis.fitting_mode.type` @@ -662,7 +663,7 @@ binding — this is a Family-B engine-swap mechanism; only the user-facing surface and the CIF block name change. Affected ADRs: this rename adds a small amendment to -[`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +[`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) (category and CIF tag both move from `calculation` to `calculator`) and to [`selector-families.md`](selector-families.md) (the example row for Family B). @@ -685,16 +686,16 @@ separate, smaller table. ### In scope — every selector with a writable type surface -| # | Owner | Today | Proposed Python | CIF today | CIF proposed | Mech | Source | -| --- | ---------- | ---------------------------------------------- | ---------------------------------- | --------------------------------------- | -------------------- | -------------- | ------------------------------------------------- | -| 1 | analysis | `analysis.minimizer_type = 'X'` | `analysis.minimizer.type = 'X'` | `_fitting.minimizer_type` | `_minimizer.type` | A | `analysis/analysis.py:1022` | -| 2 | experiment | `experiment.peak_profile_type = 'X'` | `experiment.peak.type = 'X'` | `_peak.profile_type` | `_peak.type` | A | `experiment/item/base.py:514` | -| 3 | experiment | `experiment.background_type = 'X'` | `experiment.background.type = 'X'` | (none — only `_pd_background.*` loop) | `_background.type` | A | `experiment/item/bragg_pd.py:184` | -| 4 | experiment | `experiment.extinction_type = 'X'` | `experiment.extinction.type = 'X'` | (none — only active class's own fields) | `_extinction.type` | A | `experiment/item/base.py:312` | -| 5 | experiment | `experiment.calculation.calculator_type = 'X'` | `experiment.calculator.type = 'X'` | `_calculation.calculator_type` | `_calculator.type` | B + §8 rename | `experiment/categories/calculation/default.py:50` | -| 6 | project | `project.rendering.chart_engine = 'X'` | `project.chart.type = 'X'` | `_rendering.chart_engine` | `_chart.type` | B + §8 split | `project/categories/rendering/default.py:100` | -| 7 | project | `project.rendering.table_engine = 'X'` | `project.table.type = 'X'` | `_rendering.table_engine` | `_table.type` | B + §8 split | `project/categories/rendering/default.py:109` | -| 8 | analysis | `analysis.fitting_mode_type = 'X'` | `analysis.fitting_mode.type = 'X'` | `_fitting.mode_type` | `_fitting_mode.type` | C + §8 promote | `analysis/analysis.py:960` | +| # | Owner | Today | Proposed Python | CIF today | CIF proposed | Mech | Source | +| --- | ---------- | ---------------------------------------------- | ------------------------------------ | --------------------------------------- | ----------------------- | -------------- | ------------------------------------------------- | +| 1 | analysis | `analysis.minimizer_type = 'X'` | `analysis.minimizer.type = 'X'` | `_fitting.minimizer_type` | `_minimizer.type` | A | `analysis/analysis.py:1022` | +| 2 | experiment | `experiment.peak_profile_type = 'X'` | `experiment.peak.type = 'X'` | `_peak.profile_type` | `_peak.type` | A | `experiment/item/base.py:514` | +| 3 | experiment | `experiment.background_type = 'X'` | `experiment.background.type = 'X'` | (none — only `_pd_background.*` loop) | `_background.type` | A | `experiment/item/bragg_pd.py:184` | +| 4 | experiment | `experiment.extinction_type = 'X'` | `experiment.extinction.type = 'X'` | (none — only active class's own fields) | `_extinction.type` | A | `experiment/item/base.py:312` | +| 5 | experiment | `experiment.calculation.calculator_type = 'X'` | `experiment.calculator.type = 'X'` | `_calculation.calculator_type` | `_calculator.type` | B + §8 rename | `experiment/categories/calculation/default.py:50` | +| 6 | project | `project.rendering.chart_engine = 'X'` | `project.rendering_plot.type = 'X'` | `_rendering.chart_engine` | `_rendering_plot.type` | B + §8 split | `project/categories/rendering/default.py:100` | +| 7 | project | `project.rendering.table_engine = 'X'` | `project.rendering_table.type = 'X'` | `_rendering.table_engine` | `_rendering_table.type` | B + §8 split | `project/categories/rendering/default.py:109` | +| 8 | analysis | `analysis.fitting_mode_type = 'X'` | `analysis.fitting_mode.type = 'X'` | `_fitting.mode_type` | `_fitting_mode.type` | C + §8 promote | `analysis/analysis.py:960` | Mechanism legend (recap): @@ -820,7 +821,7 @@ member and exposes `category.type` plus `category.show_supported()`. Replace the `analysis.fitting_mode_type` description with `analysis.fitting_mode.type` and document the new `FittingMode` category (§8b). -- [`python-cif-category-correspondence.md`](../suggestions/python-cif-category-correspondence.md) +- [`python-cif-category-correspondence.md`](python-cif-category-correspondence.md) — the §"Owner-level switchable selectors" table becomes obsolete; remove the "deliberate abstraction" exception. Update every entry to the `_.type` form. @@ -837,15 +838,15 @@ member and exposes `category.type` plus `category.show_supported()`. - [`display-ux.md`](display-ux.md) — replace every reference to `project.rendering`, `_rendering.chart_engine`, and `_rendering.table_engine` with the post-§8a shape: - `project.chart.type`, `project.table.type`, CIF blocks `_chart.*` and - `_table.*`. Drop the writable-selector contract that puts chart/table - engines on the `rendering` category; document instead that each - renderer lives on its own category with the canonical `category.type` - surface. + `project.rendering_plot.type`, `project.rendering_table.type`, CIF + blocks `_rendering_plot.*` and `_rendering_table.*`. Drop the + writable-selector contract that puts chart/table engines on the + `rendering` category; document instead that each renderer lives on its + own category with the canonical `category.type` surface. - [`category-owner-sections.md`](category-owner-sections.md) — update the `ProjectConfig` children list: drop `Rendering`; add `Chart` and `Table` as siblings. Update the `_rendering.*` CIF block reference to - `_chart.*` and `_table.*`. + `_rendering_plot.*` and `_rendering_table.*`. (A grep against `docs/dev/adrs/accepted/` for the renamed Python names and CIF tags surfaced four additional hits that turned out to be generic @@ -918,8 +919,8 @@ visible at a glance. ``` data_project -_chart.type plotly -_table.type rich +_rendering_plot.type plotly +_rendering_table.type rich ``` The `_rendering.*` block is gone; two single-purpose blocks replace it @@ -1040,11 +1041,11 @@ project.experiments['hrpt'].background.create(id='1', order=0, coef=0.42) project.experiments['hrpt'].calculator.show_supported() project.experiments['hrpt'].calculator.type = 'cryspy' -project.chart.show_supported() -project.chart.type = 'plotly' +project.rendering_plot.show_supported() +project.rendering_plot.type = 'plotly' -project.table.show_supported() -project.table.type = 'rich' +project.rendering_table.show_supported() +project.rendering_table.type = 'rich' # Family C — active-sibling selector (same surface) project.analysis.fitting_mode.show_supported() diff --git a/docs/dev/adrs/accepted/value-selector-discovery.md b/docs/dev/adrs/accepted/value-selector-discovery.md new file mode 100644 index 000000000..93f982e52 --- /dev/null +++ b/docs/dev/adrs/accepted/value-selector-discovery.md @@ -0,0 +1,223 @@ +# ADR: Value-Selector Discovery + +## Status + +Accepted. + +## Date + +2026-05-31 + +## Group + +User-facing API. + +## Implementation Note + +This ADR was accepted as part of the structure-view settings cleanup, +which also amended +[`crysview-structure-visualization.md`](crysview-structure-visualization.md) +for the structure-view settings split; no separate +`structure-view-settings` ADR exists. + +## Context + +EasyDiffraction is used by scientists who explore the API in notebooks, +so every "pick one of a fixed set" choice should be discoverable in +place. + +Two accepted ADRs set up that expectation but only half-deliver it: + +- [`enum-backed-closed-values.md`](enum-backed-closed-values.md) + requires every finite closed set to be a `(str, Enum)` and names + "finite choices are discoverable" as a consequence. It also makes enum + members the source of truth for **descriptions**. +- [`switchable-category-owned-selectors.md`](switchable-category-owned-selectors.md) + gives the three category-level selector families from + [`selector-families.md`](selector-families.md) — backend, + switchable-category, and active-sibling — a uniform public shape: + + ```python + category.type = 'new-type' + category.show_supported() + ``` + + All three are _category-level_ selectors: each has an owner + `_swap_` hook, the category **is** the single selector, and + `show_supported()` lives on the category. + +But many enumerated choices are **not** category-level selectors. They +are plain enumerated _fields_ inside a category — there is no backend or +category to swap, only a value the consumer reads. Examples today: + +- `structure_style.atom_view`, `structure_style.color_scheme` +- the `experiment.experiment_type` axes (`sample_form`, `beam_mode`, + `radiation_probe`, `scattering_type`) +- `verbosity` levels, and similar project-owned closed sets + +These value fields have **no discovery surface**. A scientist must read +source or trigger a validation error to learn the options — exactly the +gap the enum-backed ADR promised to close. They are declared ad hoc as +`StringDescriptor(... MembershipValidator(allowed=[m.value for m in E]))`, +duplicating the enum-to-allowed wiring at every site. + +Not every `MembershipValidator`-backed field qualifies. Some validate +against **dynamic or external** sets rather than a project-owned closed +enum: `atom_sites.type_symbol` (CrySPY isotope symbols from +`DATABASE['Isotopes']`), `atom_sites.wyckoff_letter` (space-group +dependent), `space_group.name_h_m` (CrySPY H-M symbols), and +`space_group.it_coordinate_system_code` (derived from the current H-M +symbol). These are boundary-facing CIF/science values, not `(str, Enum)` +closed sets with a static `.default()`/`.description()`; they are out of +scope here (see Decision and Deferred Work). + +## Decision + +Recognize a fourth selector shape — the **value selector** — and give it +a discovery surface symmetric with the three category-level families. + +1. **Definition.** A value selector is an enumerated descriptor field + over a **project-owned, static `(str, Enum)`** closed set (per + [`enum-backed-closed-values.md`](enum-backed-closed-values.md)) whose + assignment sets a value (no class swap, no `_swap_` hook). It + is distinct from the three category-level families in + [`selector-families.md`](selector-families.md), which swap a category + instance, rebind a backend, or activate siblings. A field whose + allowed set is **dynamic, external, or context-dependent** (validated + against a database or another field rather than a closed enum) is + **not** a value selector and is out of scope — see Decision 4. + +2. **Discovery lives on the descriptor.** A value selector exposes + `show_supported()` on the descriptor itself, reusing the **same** + `render_table` presentation and `*`-marks-current convention that + category-level `show_supported()` uses, with the value column and + descriptions sourced from the enum (per + [`enum-backed-closed-values.md`](enum-backed-closed-values.md)): + + ```python + project.structure_style.color_scheme = 'vesta' # set (string or member) + project.structure_style.color_scheme.show_supported() + # Color Scheme types + # Value Description + # jmol Jmol / CPK colour scheme + # * vesta VESTA colour scheme + ``` + + This sits beside the category-level shape, not replacing it: + + ```python + project.rendering_structure.type = 'threejs' # category-level selector + project.rendering_structure.show_supported() + ``` + +3. **One descriptor, one source of truth.** Introduce a core + `EnumDescriptor` bound to a `(str, Enum)` class. It derives the + `MembershipValidator` and the default (`Enum.default()`) from the + enum, stores the enum, and renders `show_supported()` from + `Enum.description`. It replaces the manual + `StringDescriptor + MembershipValidator(allowed=[...])` pattern at + every value-selector site, so the enum is wired once. + +4. **Scope.** A non-switchable field adopts `EnumDescriptor` **only when + its allowed set is a project-owned, static `(str, Enum)`** — e.g. + `atom_view`, `color_scheme`, the `experiment_type` axes, `verbosity`. + Each such enum must expose `.default()` and `.description`; add them + where missing. Explicitly **out of scope**: + - Category-level `.type` selectors (the three families) — unchanged; + they keep their category-level `show_supported()`. + - **Dynamic / external / context-dependent** membership validators — + `atom_sites.type_symbol`, `atom_sites.wyckoff_letter`, + `space_group.name_h_m`, `space_group.it_coordinate_system_code`, + and any field whose allowed values come from a database, another + field, or runtime context. These keep their existing + `MembershipValidator` and current validation behavior; they do + **not** become `EnumDescriptor`s and do **not** gain + `show_supported()` under this ADR. A separate dynamic-choice + discovery surface is deferred. + + An implementation audit classifies each `MembershipValidator` field + as value selector, category-level selector, or dynamic/external + before any migration. + +5. **No category-level `show_supported()` on bundle categories.** A + category that holds several value selectors (e.g. `structure_style`, + `experiment.experiment_type`) does **not** gain a category-level + `show_supported()`. "Supported values of what?" has no single answer + on a multi-field bundle; discovery is per value selector. (A + clearly-named grouped overview may be added later for tightly-related + axis bundles — see Deferred Work.) + +6. **Numbers are not selectors.** Bounded numeric fields + (`adp_probability`, `atom_scale`, `range_*`, …) keep plain numeric + descriptors. Their limits live in the docstring and the validation + error; they have nothing to enumerate, so no `show_supported()`. + +7. **`help()` integration.** Because descriptors already expose `help()` + (per [`help-discoverability.md`](help-discoverability.md)), + `show_supported()` is surfaced automatically when a user calls + `help()` on the descriptor — `help()` says what the field is, + `show_supported()` lists its values. + +## Consequences + +- Every finite choice is discoverable at its own selector, finally + delivering the enum-backed ADR's "discoverable" promise for value + fields, with one table format shared across all selector shapes. +- The public surface is uniform: _every_ selector answers + `show_supported()`. Category-level selectors answer at + `category.show_supported()`; value selectors at + `category.field.show_supported()`. The asymmetry is intentional — a + category-level selector _is_ the category, whereas a value field is + one of several on its category. +- No deeper category tree: `show_supported()` is a method on the + descriptor the getter already returns, not a nested sub-category. +- Immutable categories (per + [`immutable-experiment-type.md`](immutable-experiment-type.md)) still + expose `show_supported()` as read-only discovery, even where the value + is creation-time only. +- This is a project-wide migration of the **enum-backed** value + selectors only (the audit pins the exact set; the dynamic/external + validators above are excluded and keep their current behavior). + Affected enums gain `.default()`/`.description` where missing. The + phased rollout also reorganized the structure-view categories that + motivated this ADR. + +## Alternatives Considered + +- **Promote each value selector to a switchable category.** Rejected: + the three families in [`selector-families.md`](selector-families.md) + swap a category instance, a backend, or siblings via a `_swap_` + hook; a colour scheme or atom-view mode swaps nothing. Forcing that + machinery would either nest a category under a category + (`structure_style.color_scheme.type` — a deeper tree, which violates + the flat-sibling rule) or pollute the top level + (`project.color_scheme`). +- **Category-level `show_supported()` listing every enumerated field on + the bundle.** Rejected as the primary surface: ambiguous on a + multi-field category and redundant with the per-descriptor method. + Kept open only as an optional, clearly-named grouped overview for + related axis bundles (Deferred Work). +- **Rely on `help()`, docstrings, and validation errors.** Rejected: + `help()` describes the field but does not enumerate accepted values + with the active one marked, and users should not have to trigger an + error to learn the options. +- **Keep `StringDescriptor + MembershipValidator(allowed=[...])` and + bolt on `show_supported()` per site.** Rejected: duplicates the + enum-to-allowed wiring, is easy to forget, and has no single source of + truth. `EnumDescriptor` binds the enum once and derives validation, + default, and discovery together. + +## Deferred Work + +- A separate **dynamic-choice descriptor** giving a discovery surface (a + `show_supported()`-style listing) to fields whose allowed set is + dynamic, external, or context-dependent — `atom_sites.type_symbol`, + `atom_sites.wyckoff_letter`, `space_group.name_h_m`, + `space_group.it_coordinate_system_code`, and similar. Out of scope + here; these keep their current `MembershipValidator` until such a + descriptor exists. +- An optional, clearly-named grouped overview for tightly-related axis + bundles (notably `experiment.experiment_type`'s four axes shown + together). Not part of the initial rollout. +- Adopting `EnumDescriptor` at value-selector sites beyond those the + motivating plan touches, if any remain after the audit. diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 2f86cd54b..9a04a6a6c 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -13,40 +13,43 @@ folders. ## ADR Index -| Group | Status | Title | Short description | Link | -| -------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | -| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | -| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | -| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | -| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | -| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | -| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | -| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | -| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) | -| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | -| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | -| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | -| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | -| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | -| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | -| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | -| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | -| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | -| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | -| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | -| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | -| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | -| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | -| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds an IUCr submission report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | -| Persistence | Suggestion | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then proposes scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](suggestions/python-cif-category-correspondence.md) | -| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | -| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | -| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | -| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | -| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) | -| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and publication metadata surfaces. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) | -| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | -| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | -| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | -| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | +| Group | Status | Title | Short description | Link | +| -------------------- | ---------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | +| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | +| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | +| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | +| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | +| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | +| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | +| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | +| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) | +| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | +| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | +| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | +| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | +| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | +| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | +| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | +| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | +| Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) | +| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | +| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | +| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | +| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | +| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | +| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | +| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | +| Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) | +| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | +| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | +| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | +| User-facing API | Accepted | Crystal Structure 3D Visualization | Adds a renderer-neutral scene model drawn by ASCII and interactive Three.js engines for viewing crystal structures. | [`crysview-structure-visualization.md`](accepted/crysview-structure-visualization.md) | +| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | +| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) | +| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and clean report-CIF metadata policy. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) | +| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | +| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | +| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | +| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | +| User-facing API | Accepted | Value-Selector Discovery | Gives enumerated value fields a per-descriptor `show_supported()`, beside the three category-level selector families. | [`value-selector-discovery.md`](accepted/value-selector-discovery.md) | diff --git a/docs/dev/adrs/suggestions/documentation-ci-build.md b/docs/dev/adrs/suggestions/documentation-ci-build.md new file mode 100644 index 000000000..f3a858832 --- /dev/null +++ b/docs/dev/adrs/suggestions/documentation-ci-build.md @@ -0,0 +1,189 @@ +# ADR: Documentation CI and Build Verification + +**Status:** Proposed +**Date:** 2026-05-31 + +## Group + +Documentation. + +## Context + +User-facing documentation can drift from the Python API, persisted CIF +tags, tutorial outputs, and MkDocs navigation. Recent examples included +stale selector methods, an outdated minimizer list, missing Quick +Reference navigation, and code snippets that no longer matched current +category-owned selectors. + +The documentation build should catch more of this drift before review. +The checks should remain understandable to scientific contributors and +should distinguish source documentation from generated notebooks. + +## Decision + +Add documentation verification in layers, starting with checks that are +cheap, deterministic, and useful in local development. + +### 1. Build the MkDocs site in strict mode + +Run the docs build in CI with: + +```shell +mkdocs build --strict +``` + +or the equivalent `pixi` task once one is added. This should catch +missing navigation entries, broken internal references reported by +MkDocs, and warnings that should fail documentation CI. + +### 2. Keep API reference generation source-driven + +Continue using source-driven API documentation. If the current API +reference generation is not already based on `mkdocstrings`, adopt or +standardize on `mkdocstrings[python]` so public signatures and docstring +content are pulled from the installed package rather than copied into +manual Markdown. + +### 3. Add snippet smoke tests for user-facing examples + +Add a small documentation smoke-test script that extracts or imports +selected Python snippets from: + +- `docs/docs/quick-reference/index.md` +- `docs/docs/user-guide/first-steps.md` +- `docs/docs/user-guide/analysis-workflow/*.md` + +The smoke tests should focus on API shape, not full calculations. They +should instantiate small projects, check public method names, and avoid +network, notebooks, and real calculator backends unless explicitly +covered by slower script or integration tests. + +### 4. Check generated tutorial freshness separately + +Tutorial notebooks remain generated artifacts. CI should verify that +`pixi run notebook-prepare` leaves generated `.ipynb` files unchanged, +or expose an explicit `notebook-prepare-check` task if the project wants +a faster no-write mode. + +### 5. Check links with a dedicated link checker + +Use `lychee` or an equivalent link checker for Markdown and generated +HTML links. Configure it with an allowlist for intentionally unstable or +rate-limited external domains, and cache results where practical. + +### 6. Add prose and spelling checks incrementally + +Use `codespell` first for low-noise spelling checks. Consider `Vale` +after the project has a small EasyDiffraction style vocabulary and an +allowlist for crystallographic terms, package names, and CIF tags. + +## Options Considered + +### MkDocs strict build + +Pros: + +- aligns with the existing MkDocs build path +- catches navigation and internal-reference problems early +- low conceptual overhead for contributors + +Cons: + +- does not execute Python snippets +- external URLs require a separate checker + +### mkdocstrings for API pages + +Pros: + +- keeps API reference tied to source signatures and docstrings +- supports Python docstring styles already used by the project +- reduces manual API copy/paste drift + +Cons: + +- requires a docs dependency if not already present +- only helps reference pages, not narrative examples + +### Documentation snippet smoke tests + +Pros: + +- directly catches renamed methods and stale public API examples +- can stay fast if limited to no-backend API construction +- complements unit tests because it validates documented workflows + +Cons: + +- snippet extraction needs conventions or explicit markers +- examples involving downloaded data or calculators need fixtures or + slower test tiers + +### lychee link checking + +Pros: + +- checks Markdown, HTML, and external URLs +- has a GitHub Action and CLI workflow +- can run on a schedule for external-link rot + +Cons: + +- external sites can be flaky or rate-limited +- needs ignore rules for intentionally unreachable example URLs + +### Vale prose linting + +Pros: + +- catches grammar/style issues beyond spelling +- supports project-specific style rules +- can make docs more consistent for non-programmer users + +Cons: + +- needs careful configuration to avoid noisy scientific false positives +- style-rule debates can slow feature reviews if introduced too broadly + +### codespell spelling checks + +Pros: + +- fast, simple, and available through pre-commit and CI +- catches common typos in docs and code comments +- lower adoption cost than full prose linting + +Cons: + +- needs ignore words for crystallography, CIF tags, names, and package + identifiers +- does not catch grammar or stale API examples + +## Consequences + +### Positive + +- Documentation drift becomes visible before merge. +- User-facing examples are more likely to match the current API. +- Generated notebooks remain controlled by their existing + source-of-truth workflow. +- Link and prose quality can improve without blocking on a full + documentation-system redesign. + +### Trade-offs + +- CI gains more jobs or longer docs jobs. +- New tools require dependency and configuration maintenance. +- External link checking can produce intermittent failures unless + scheduled, cached, or configured with retries and an allowlist. + +## Deferred Work + +- Decide whether link checking runs on every pull request, nightly, or + both. +- Decide whether snippet smoke tests extract fenced code blocks + automatically or rely on explicitly named snippets. +- Decide whether docs CI should build only source Markdown or also build + rendered notebooks. +- Add the chosen checks to `pixi.toml`, CI configuration, and developer + documentation after this ADR is accepted. diff --git a/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv b/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv new file mode 100644 index 000000000..cb80e3bb9 --- /dev/null +++ b/docs/dev/benchmarking/20260531-230149_darwin-arm64_py314_tutorial-benchmarks.csv @@ -0,0 +1,26 @@ +tutorial_name,elapsed_seconds,status +ed-1.py,15.289,ok +ed-2.py,20.224,ok +ed-3.py,35.924,ok +ed-4.py,4.900,ok +ed-5.py,44.665,ok +ed-6.py,72.056,ok +ed-7.py,123.342,ok +ed-8.py,122.295,ok +ed-9.py,9.181,ok +ed-10.py,39.141,ok +ed-11.py,10.198,ok +ed-12.py,8.380,ok +ed-13.py,25.059,ok +ed-14.py,19.839,ok +ed-15.py,27.564,ok +ed-16.py,63.262,ok +ed-17.py,83.813,ok +ed-18.py,7.138,ok +ed-20.py,40.791,ok +ed-21.py,78.117,ok +ed-22.py,38.354,ok +ed-23.py,23.290,ok +ed-24.py,4.912,ok +ed-25.py,27.338,ok +ed-26.py,28.586,ok diff --git a/docs/dev/issues/closed.md b/docs/dev/issues/closed.md index 4186146ed..c3574c2f2 100644 --- a/docs/dev/issues/closed.md +++ b/docs/dev/issues/closed.md @@ -6,24 +6,23 @@ Issues that have been fully resolved. Kept for historical reference. ## 103. Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative -Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). Minimizer -categories now declare `_engine_sync_skip_keys`, and analysis sync -filters against that set instead of hardcoding skipped keys. +Closed by the emcee minimizer implementation. Minimizer categories now +declare `_engine_sync_skip_keys`, and analysis sync filters against that +set instead of hardcoding skipped keys. --- ## 101. Remove Dead Branch in `_fit_state_categories` -Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). The -deterministic branch that returned the same category list as the -fallthrough path was removed while preserving unsupported `result_kind` -warning behavior. +Closed by the emcee minimizer implementation. The deterministic branch +that returned the same category list as the fallthrough path was removed +while preserving unsupported `result_kind` warning behavior. --- ## 100. Collapse Duplicate Predictive-Cache-Key Helpers -Closed by [`emcee-minimizer.md`](../plans/emcee-minimizer.md). +Closed by the emcee minimizer implementation. `posterior_predictive_cache_key()` in `analysis.fit_helpers.bayesian` is now the single helper used by analysis, plotting, and project display code. diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index 81bbd9cb3..d1d400759 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -1790,6 +1790,120 @@ Requirements: --- +## 108. 🟢 Smarter Automatic Bond Detection (Near-Neighbour Analysis) + +**Type:** UX / Visualization + +crysview generates bonds with the cif_core distance rule +(`min_bond_distance_cutoff ≤ d ≤ r_bond(A) + r_bond(B) + bond_distance_incr`), +then prunes to the first coordination shell — a contact survives only if +it is within `1.3×` the nearer atom's nearest-neighbour distance +(`COORDINATION_SHELL_FACTOR` in `display/structure/builder.py`). This +stop-gap handles the common cases (e.g. LBCO renders just the Co–O +octahedron) without a new dependency, but the fixed factor is still a +heuristic: it can over-prune strongly distorted shells (e.g. elongated +Jahn–Teller octahedra) or under-prune others, and it is not yet +user-configurable. + +**Fix:** consider a robust, configurable near-neighbour algorithm for +automatic "reasonable" bonding — e.g. a Voronoi / solid-angle method +such as pymatgen's `CrystalNN` or `VoronoiNN`, which weights neighbours +by solid angle instead of a single relative cutoff. The Voronoi route is +the most robust across arbitrary structures but introduces a heavyweight +dependency (pymatgen), so it needs a dependency decision; an +ASE/Jmol-style multiplicative covalent tolerance is lighter but, like +the current factor, cannot separate shells when ionic-cation covalent +radii are large. + +**Depends on:** dependency decision for pymatgen (if the Voronoi route +is chosen). + +--- + +## 109. 🟢 Let More Tables Adapt to Terminal Width + +**Type:** UX / Display + +`list_tutorials` now renders its table at the real terminal width via a +new optional `width` parameter threaded through the table render path +(`render_table` → `TableRenderer.render` → backend `render`; Rich +applies it, the HTML backend ignores it). Every other table and all log +output still go through the shared Rich console, whose width is floored +at `ConsoleManager._MIN_CONSOLE_WIDTH = 130` ("to avoid cramped +layouts"). On a standard ~80-column terminal that floor makes wide +tables overflow and soft-wrap badly. + +**Fix:** decide on a global policy — either have `_detect_width` trust +the detected terminal width (keeping 130 only as a fallback when +detection fails), or pass the terminal width into more table call sites +the way `list_tutorials` now does. A global change affects every table +(fit results, parameters, ...) and all logs, so weigh it against the +deliberate minimum-width choice. + +**Depends on:** related to issue 62. + +--- + +## 110. 🟢 Render Styled Multi-Line Table Cells in the HTML Backend + +**Type:** Display / Notebook parity + +`list_tutorials` shows a two-line cell in the terminal — a colored title +on the first line and a dimmed description on the second — using Rich +markup and an embedded newline. The Jupyter table backend +(`PandasTableBackend`) cannot render this: `_strip_rich_markup` only +matches a single full-cell `[color]text[/color]`, and HTML collapses the +newline, so the markup would show as literal text. `list_tutorials` is +therefore gated via `in_jupyter()` to show only the plain title in +notebooks, which drops the description and the color there. + +**Fix:** teach the HTML backend to render the same styling — translate +embedded newlines to `
`, map `[dim]` to reduced opacity, and accept +multiple/mixed markup tags per cell — then remove the terminal-only gate +in `list_tutorials` so notebooks also get the styled two-line entry. + +**Depends on:** related to issue 62. + +--- + +## 111. 🟢 Add Test Coverage for `list_tutorials` Two-Line Rendering + +**Type:** Test coverage + +The `list_tutorials` table gained a styled two-line cell (colored title +plus dimmed description), a terminal-only `in_jupyter()` gate that falls +back to the plain title, and a new optional `width` parameter on the +table render path. Existing tests only assert that titles appear in the +output. + +**Fix:** add unit tests for the description line appearing in the +terminal (non-Jupyter) path, the Jupyter-gated path showing the plain +title with no literal Rich markup, and the `width` parameter sizing the +rendered Rich table. Run `pixi run fix` / `check` / `unit-tests` to +confirm the shared-renderer signature change. + +**Depends on:** nothing. + +--- + +## 112. 🟢 Suppress the Redundant Row-Index Column in Tables + +**Type:** Display / UX + +`TableRenderer._prepare_dataframe` bumps the DataFrame index to 1-based, +and both the Rich and pandas backends always render it as the first +column. For tables that already carry an explicit identifier — e.g. +`list_tutorials`, whose `id` column duplicates that 1-based counter — +the leading index column is redundant and reads as a duplicate. + +**Fix:** add an opt-out (e.g. a `show_index` flag on the render path) so +callers with their own id column can hide the auto-generated index, or +only render the index column when no explicit id column is present. + +**Depends on:** nothing. + +--- + ## Summary | # | Issue | Severity | Type | @@ -1880,3 +1994,8 @@ Requirements: | 105 | Remove orphaned fit-result reset helper | 🟢 Low | Cleanup | | 106 | Document `FitResultBase.result_kind` default | 🟢 Low | Code readability | | 107 | Validate CIF report vs IUCr dictionaries | 🟡 Med | Test coverage | +| 108 | Smarter automatic bond detection (near-neighbour) | 🟢 Low | UX / Visualization | +| 109 | Let more tables adapt to terminal width | 🟢 Low | UX / Display | +| 110 | Styled multi-line table cells in HTML backend | 🟢 Low | Display / Notebook parity | +| 111 | Test coverage for `list_tutorials` rendering | 🟢 Low | Test coverage | +| 112 | Suppress redundant row-index column in tables | 🟢 Low | Display / UX | diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index c6d7fb0e7..1b3e42e91 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -254,6 +254,7 @@ │ ├── 🏷️ class GenericIntegerDescriptor │ ├── 🏷️ class GenericParameter │ ├── 🏷️ class StringDescriptor +│ ├── 🏷️ class EnumDescriptor │ ├── 🏷️ class BoolDescriptor │ ├── 🏷️ class NumericDescriptor │ ├── 🏷️ class IntegerDescriptor @@ -452,6 +453,12 @@ │ │ │ │ │ └── 🏷️ class Cell │ │ │ │ └── 📄 factory.py │ │ │ │ └── 🏷️ class CellFactory +│ │ │ ├── 📁 geom +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class Geom +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class GeomFactory │ │ │ ├── 📁 space_group │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py @@ -482,6 +489,53 @@ │ │ └── 📄 plotly.py │ │ ├── 🏷️ class PowderCompositeRows │ │ └── 🏷️ class PlotlyPlotter +│ ├── 📁 structure +│ │ ├── 📁 assets +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 colors.py +│ │ │ ├── 📄 elements.py +│ │ │ └── 📄 radii.py +│ │ ├── 📁 renderers +│ │ │ ├── 📁 vendor +│ │ │ │ └── 📁 threejs +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 ascii.py +│ │ │ │ ├── 🏷️ class _Orientation +│ │ │ │ └── 🏷️ class AsciiStructureRenderer +│ │ │ ├── 📄 base.py +│ │ │ │ └── 🏷️ class StructureRendererBase +│ │ │ ├── 📄 raster.py +│ │ │ │ ├── 🏷️ class _Canvas +│ │ │ │ └── 🏷️ class RasterStructureRenderer +│ │ │ └── 📄 threejs.py +│ │ │ └── 🏷️ class ThreeJsStructureRenderer +│ │ ├── 📁 templates +│ │ ├── 📄 __init__.py +│ │ ├── 📄 builder.py +│ │ │ ├── 🏷️ class FeatureAvailability +│ │ │ ├── 🏷️ class _RenderContext +│ │ │ └── 🏷️ class _SceneAtom +│ │ ├── 📄 enums.py +│ │ │ ├── 🏷️ class ViewerEngineEnum +│ │ │ ├── 🏷️ class AtomViewEnum +│ │ │ └── 🏷️ class ColorSchemeEnum +│ │ ├── 📄 scene.py +│ │ │ ├── 🏷️ class AtomSphere +│ │ │ ├── 🏷️ class OccupancyWedge +│ │ │ ├── 🏷️ class OccupancyWedgeSphere +│ │ │ ├── 🏷️ class AdpEllipsoid +│ │ │ ├── 🏷️ class Bond +│ │ │ ├── 🏷️ class MomentArrow +│ │ │ ├── 🏷️ class CellEdge +│ │ │ ├── 🏷️ class CellEdges +│ │ │ ├── 🏷️ class AxisArrow +│ │ │ ├── 🏷️ class AxisTriad +│ │ │ ├── 🏷️ class TextLabel +│ │ │ ├── 🏷️ class LegendEntry +│ │ │ └── 🏷️ class StructureScene +│ │ └── 📄 viewing.py +│ │ ├── 🏷️ class ViewerFactory +│ │ └── 🏷️ class Viewer │ ├── 📁 tablers │ │ ├── 📄 __init__.py │ │ ├── 📄 base.py @@ -514,6 +568,8 @@ │ │ ├── 🏷️ class TableEngineEnum │ │ ├── 🏷️ class TableRenderer │ │ └── 🏷️ class TableRendererFactory +│ ├── 📄 theme.py +│ │ └── 🏷️ class DisplayThemeColors │ └── 📄 utils.py │ └── 🏷️ class JupyterScrollManager ├── 📁 io @@ -541,12 +597,6 @@ │ └── 📄 results_sidecar.py ├── 📁 project │ ├── 📁 categories -│ │ ├── 📁 chart -│ │ │ ├── 📄 __init__.py -│ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class Chart -│ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class ChartFactory │ │ ├── 📁 info │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -554,32 +604,43 @@ │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class ProjectInfoFactory │ │ ├── 📁 publication +│ │ ├── 📁 rendering +│ │ ├── 📁 rendering_plot │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py -│ │ │ │ ├── 🏷️ class PublicationItemBase -│ │ │ │ ├── 🏷️ class PublicationJournal -│ │ │ │ ├── 🏷️ class PublicationJournalDate -│ │ │ │ ├── 🏷️ class PublicationJournalCoeditor -│ │ │ │ ├── 🏷️ class PublicationContactAuthor -│ │ │ │ ├── 🏷️ class PublicationBody -│ │ │ │ ├── 🏷️ class PublicationAuthor -│ │ │ │ ├── 🏷️ class PublicationAuthors -│ │ │ │ └── 🏷️ class Publication +│ │ │ │ └── 🏷️ class RenderingPlot │ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class PublicationFactory -│ │ ├── 📁 rendering +│ │ │ └── 🏷️ class RenderingPlotFactory +│ │ ├── 📁 rendering_structure +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class RenderingStructure +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class RenderingStructureFactory +│ │ ├── 📁 rendering_table +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class RenderingTable +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class RenderingTableFactory │ │ ├── 📁 report │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ │ └── 🏷️ class Report │ │ │ └── 📄 factory.py │ │ │ └── 🏷️ class ReportFactory -│ │ ├── 📁 table +│ │ ├── 📁 structure_style +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class StructureStyle +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class StructureStyleFactory +│ │ ├── 📁 structure_view │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py -│ │ │ │ └── 🏷️ class Table +│ │ │ │ └── 🏷️ class StructureView │ │ │ └── 📄 factory.py -│ │ │ └── 🏷️ class TableFactory +│ │ │ └── 🏷️ class StructureViewFactory │ │ ├── 📁 verbosity │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py @@ -598,8 +659,7 @@ │ │ └── 🏷️ class Project │ ├── 📄 project_config.py │ │ └── 🏷️ class ProjectConfig -│ ├── 📄 project_info.py -│ └── 📄 publication_loader.py +│ └── 📄 project_info.py ├── 📁 report │ ├── 📁 templates │ │ ├── 📁 html diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index 26f42e63d..23c0e68b8 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -214,6 +214,10 @@ │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py │ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 geom +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py │ │ │ ├── 📁 space_group │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py @@ -232,6 +236,26 @@ │ │ ├── 📄 ascii.py │ │ ├── 📄 base.py │ │ └── 📄 plotly.py +│ ├── 📁 structure +│ │ ├── 📁 assets +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 colors.py +│ │ │ ├── 📄 elements.py +│ │ │ └── 📄 radii.py +│ │ ├── 📁 renderers +│ │ │ ├── 📁 vendor +│ │ │ │ └── 📁 threejs +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 ascii.py +│ │ │ ├── 📄 base.py +│ │ │ ├── 📄 raster.py +│ │ │ └── 📄 threejs.py +│ │ ├── 📁 templates +│ │ ├── 📄 __init__.py +│ │ ├── 📄 builder.py +│ │ ├── 📄 enums.py +│ │ ├── 📄 scene.py +│ │ └── 📄 viewing.py │ ├── 📁 tablers │ │ ├── 📄 __init__.py │ │ ├── 📄 base.py @@ -242,6 +266,7 @@ │ ├── 📄 plotting.py │ ├── 📄 progress.py │ ├── 📄 tables.py +│ ├── 📄 theme.py │ └── 📄 utils.py ├── 📁 io │ ├── 📁 cif @@ -256,24 +281,33 @@ │ └── 📄 results_sidecar.py ├── 📁 project │ ├── 📁 categories -│ │ ├── 📁 chart +│ │ ├── 📁 info │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 info +│ │ ├── 📁 publication +│ │ ├── 📁 rendering +│ │ ├── 📁 rendering_plot │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 publication +│ │ ├── 📁 rendering_structure +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ ├── 📁 rendering_table │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 rendering │ │ ├── 📁 report │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py -│ │ ├── 📁 table +│ │ ├── 📁 structure_style +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ ├── 📁 structure_view │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 default.py │ │ │ └── 📄 factory.py @@ -286,8 +320,7 @@ │ ├── 📄 display.py │ ├── 📄 project.py │ ├── 📄 project_config.py -│ ├── 📄 project_info.py -│ └── 📄 publication_loader.py +│ └── 📄 project_info.py ├── 📁 report │ ├── 📁 templates │ │ ├── 📁 html diff --git a/docs/dev/plans/iucr-cif-tag-alignment.md b/docs/dev/plans/iucr-cif-tag-alignment.md deleted file mode 100644 index d1169e3c3..000000000 --- a/docs/dev/plans/iucr-cif-tag-alignment.md +++ /dev/null @@ -1,633 +0,0 @@ -# Plan: IUCr CIF Tag Alignment - -Implementation plan for the -[`iucr-cif-tag-alignment`](../adrs/accepted/iucr-cif-tag-alignment.md) -ADR. Follows [`AGENTS.md`](../../../AGENTS.md) — no deliberate -exceptions to those instructions. - -## ADR cross-reference - -- Primary ADR: `iucr-cif-tag-alignment.md` (accepted; this plan promoted - it from `suggestions/` during Phase 1). -- Amends (per the ADR's "ADRs amended by this ADR" section): - - [`analysis-cif-fit-state.md`](../adrs/accepted/analysis-cif-fit-state.md) - — new `_fit_result.*` fields; topology-neutral default save. - - [`minimizer-input-output-split.md`](../adrs/accepted/minimizer-input-output-split.md) - — new `_fit_result.*` examples. - - [`project-facade-and-persistence.md`](../adrs/accepted/project-facade-and-persistence.md) - — `project.summary` removed and replaced by `project.report`; - `summary.cif` no longer written. - - [`help-discoverability.md`](../adrs/accepted/help-discoverability.md) - — `project.summary.help()` → `project.report.help()`. - -## Branch and PR - -- Branch: `iucr-cif-tag-alignment` (already checked out, matches the - slug). -- PR target: `develop`. -- Do not push the branch until both Phase 1 and Phase 2 review cycles - close. - -## Decisions already made (in the ADR) - -These are settled by the accepted ADR — the plan does not re-litigate -them, only implements them: - -- **Three-tier default save:** structure-tier IUCr alignment with casing - fixes; analysis-tier topology-neutral `_fit_result.*` with - dictionary-canonical _item_ names (uppercase R / wR / DOI); - experiment-tier unchanged. Per-topology category split (`_refine_ls.*` - / `_pd_proc_ls.*` / `_reflns.*`) happens only in the IUCr export. -- **Reports system:** new `project.report` facade slot replaces the - unimplemented `project.summary` placeholder; single - `reports/.cif` file with multi-datablock layout always - starting with `data_global`. `project.save(report=True)` and - `project.report.save()` produce the file; `project.report.check()` - validates via gemmi against `cif_core.dic` / `cif_pow.dic`. -- **Handler mechanism:** per-field `iucr_name` (optional, falls back to - `names[0]`) plus category-level `IucrCategoryTransformer` subclasses - for wavelength, TOF calibration, excluded regions, symmetry - operations, and extinction reshaping. -- **ADP single-tag emission:** emit `B_*` xor `U_*` per row based on - `_atom_site.ADP_type`; both forms still accepted on read. -- **Loop-tag style:** dotted DDLm everywhere on write; both forms on - read. -- **`_easydiffraction_software`** umbrella category with three free-text - fields (`framework`, `calculator`, `minimizer`), plus a derived - `_computing.structure_refinement` string in the IUCr export. -- **gemmi** is already a project dependency (`pyproject.toml:39`); no - new dependencies needed. - -## Open questions to resolve during implementation - -- **Pixi env:** confirm `gemmi.cif.read_doc` and the dictionary - validation path are available in the project's pinned gemmi version - before P1.16 (validator implementation). If the pinned version is too - old, bump the constraint in `pyproject.toml` (counts as a plan-named - dependency change — pre-approved per AGENTS.md §Architecture because - the package is already named). -- **Tutorial scope:** identify every tutorial source under - `docs/docs/tutorials/*.py` that references `project.summary` and - update them in P1.17. If a tutorial currently uses - `project.summary.help()` as a discoverability example, it becomes - `project.report.help()`; if a tutorial expects `summary.cif`, swap to - `project.save(report=True)` and reference `reports/.cif`. -- **Extinction transformer detail:** the `(type, model)` → - `_refine_ls.extinction_method` descriptive string in §3 of the ADR - uses a Becker-Coppens taxonomy table; the implementation needs the - project's existing extinction-model selector enums to map cleanly. - Verify enum values during P1.14. - -## Concrete files likely to change - -Foundation: - -- `src/easydiffraction/io/cif/handler.py` — `CifHandler` gains - `iucr_name` parameter. - -Structure tier (casing): - -- `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py` -- `src/easydiffraction/datablocks/structure/categories/space_group/default.py` - -ADP write-side: - -- `src/easydiffraction/io/cif/serialize.py` (atom_site / atom_site_aniso - write path) - -Analysis tier (new `_fit_result.*` fields): - -- `src/easydiffraction/analysis/categories/fit_result/lsq.py` -- `src/easydiffraction/analysis/categories/fit_result/base.py` -- `src/easydiffraction/analysis/fit/` (residual / aggregate computation - site; exact module determined during P1.4) - -Project-extension `iucr_name` settings (P1.7): - -- The descriptor files enumerated in P1.7 (one `cif_handler` call per - descriptor across the analysis / experiment categories — no new - modules, no new packages). The software triple itself is built inline - by the IUCr writer (P1.11) and does **not** introduce a new category - class or default-save persistence. - -Facade rename `project.summary` → `project.report` (P1.8): - -- `src/easydiffraction/project/project.py` (replace the `summary` - property and `summary.cif` write with the new `report` facade; drop - the `as_cif()` caller; add the `report: bool = False` keyword to - `Project.save()`). -- `src/easydiffraction/summary/` → migrated to - `src/easydiffraction/report/` (either rename the package and class, or - add a fresh `report/` package whose `Report` delegates to the migrated - display methods — implementer's choice). Every live display method - (`show_report`, `show_project_info`, `show_crystallographic_data`, - `show_experimental_data`, `show_fitting_details`) is preserved - verbatim; only the placeholder `as_cif()` is dropped. -- `src/easydiffraction/report/__init__.py`, `report.py` — destination of - the migration. - -IUCr writer: - -- `src/easydiffraction/io/cif/iucr_writer.py` (new) -- `src/easydiffraction/io/cif/iucr_transformers.py` (new — holds - `IucrCategoryTransformer` subclasses) - -Validation: - -- `src/easydiffraction/report/check.py` (new — wraps `gemmi`) - -Amended ADRs: - -- `docs/dev/adrs/accepted/analysis-cif-fit-state.md` -- `docs/dev/adrs/accepted/minimizer-input-output-split.md` -- `docs/dev/adrs/accepted/project-facade-and-persistence.md` -- `docs/dev/adrs/accepted/help-discoverability.md` - -ADR promotion: - -- `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` → moved to - `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md` with status flipped - to Accepted. -- `docs/dev/adrs/index.md` (index row updated). - -Tutorials / CLI: - -- `docs/docs/tutorials/*.py` (regenerate notebooks after edits via - `pixi run notebook-prepare`). -- `src/easydiffraction/cli/` (any command that surfaces - `project.summary`). - -## Commit discipline - -When an AI agent follows this plan, **every completed Phase 1 -implementation step must be staged with explicit paths and committed -locally before moving to the next implementation step or the Phase 1 -review gate.** Follow the rules in [`AGENTS.md`](../../../AGENTS.md) → -**Commits**. Keep commits atomic, single-purpose, and aligned with the -plan steps. Do not include generated artifacts (data CIFs, project -directories, benchmark CSVs) unless the step explicitly produces them — -see **Workflow** in [`AGENTS.md`](../../../AGENTS.md) for the -generated-artifact exceptions. - -## Implementation steps (Phase 1) - -- [x] **P1.1 — Extend `CifHandler` with `iucr_name`** - - File: `src/easydiffraction/io/cif/handler.py`. - - Add an optional keyword `iucr_name: str | None = None` to - `CifHandler.__init__`. - - Add a public property / method returning the IUCr-side tag: - `iucr_name` when set, else `names[0]`. - - No call sites change in this step — the default fallback leaves - every existing handler emitting its current name. - - Commit: `Add iucr_name to CifHandler`. - -- [x] **P1.2 — Structure-tier casing fixes** - - Files: - `src/easydiffraction/datablocks/structure/categories/atom_sites/default.py`, - `src/easydiffraction/datablocks/structure/categories/space_group/default.py`. - - Rename canonical CIF tags to dictionary-canonical casing - (`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`, - `_space_group.name_H-M_alt`, - `_space_group.IT_coordinate_system_code`). Python attribute names - stay lowercase. - - Keep the old (lowercase / `wyckoff_letter`) forms in each - `CifHandler.names` list as read-only aliases so loading legacy files - still works. - - Commit: `Adopt IUCr casing for atom_site and space_group CIF tags`. - -- [x] **P1.3 — ADP single-tag emission per row** - - File: `src/easydiffraction/io/cif/serialize.py` (and any helper - called by it). - - When emitting `_atom_site_aniso.*` and `_atom_site.B_iso_or_equiv` / - `_atom_site.U_iso_or_equiv`, choose `B_*` or `U_*` per row based on - `_atom_site.ADP_type`; omit the other family for that row. - - Read side unchanged (both families still accepted). - - Commit: `Emit one ADP family per atom_site row on save`. - -- [x] **P1.4 — Analysis tier: new `_fit_result.*` fields** - - Files: `src/easydiffraction/analysis/categories/fit_result/lsq.py`, - `src/easydiffraction/analysis/categories/fit_result/base.py`, plus - the fit-computation site (search for where `n_data_points`, - `reduced_chi_square` are currently populated). - - Declare new descriptors under `_fit_result.*` with - dictionary-canonical _item_ names (uppercase R / wR): - `R_factor_all`, `wR_factor_all`, `R_factor_gt`, `wR_factor_gt`, - `prof_R_factor`, `prof_wR_factor`, `prof_wR_expected`, - `number_restraints`, `number_constraints`, `shift_over_su_max`, - `shift_over_su_mean`, `profile_function`, `background_function`, - `threshold_expression`, `number_reflns_total`, `number_reflns_gt`. - - Wire computation: R-factors from residuals; restraint / constraint - counts from the analysis model; profile / background descriptors - from the active peak / background categories; reflns aggregates from - refln data. - - Fields not meaningful for a given fit (e.g. `prof_R_factor` for SC) - stay unset / `None`. - - Commit: - `Add IUCr-canonical fit_result fields to LeastSquaresFitResult`. - -- [x] **P1.5 — Amend `analysis-cif-fit-state.md`** - - File: `docs/dev/adrs/accepted/analysis-cif-fit-state.md`. - - Document the new `_fit_result.*` fields, the topology-neutral - default-save policy, and the per-topology IUCr-export remapping - deferred to §3 of the iucr-cif-tag-alignment ADR. - - Commit: - `Amend analysis-cif-fit-state ADR for new fit_result fields`. - -- [x] **P1.6 — Amend `minimizer-input-output-split.md`** - - File: `docs/dev/adrs/accepted/minimizer-input-output-split.md`. - - Update the `_fit_result.*` examples in §3 to reflect the new field - set from P1.4. - - Commit: `Amend minimizer-input-output-split ADR examples`. - -- [x] **P1.7 — Set `iucr_name` on project-extension descriptors** - - No new category. The ADR's `_easydiffraction_software` triple is a - **report-only projection**: it is derived inline by the IUCr writer - in P1.11 from existing state (`easydiffraction` package version, - `project.analysis.calculator.type`, - `project.analysis.minimizer.type`, and per-backend version - metadata). It is **not** persisted to `analysis/analysis.cif` and no - new category class is added. - - This step's actual work is the `_easydiffraction_*` prefix rename - for existing project-extension descriptors at IUCr export time. For - each project-extension category, set the matching descriptor's - `cif_handler` `iucr_name` to the prefixed form so the IUCr writer - emits `_easydiffraction_.` while the default save - keeps the bare-category form (`_minimizer.*`, `_calculator.*`, - etc.). - - Touched descriptors: - - `_minimizer.*` → `iucr_name='_easydiffraction_minimizer.*'` - (settings only — type, tolerance, max_iter, …). - - `_calculator.*` → `iucr_name='_easydiffraction_calculator.*'`. - - `_fitting_mode.*` → `iucr_name='_easydiffraction_fitting_mode.*'`. - - `_alias.*` → `iucr_name='_easydiffraction_alias.*'`. - - `_constraint.*` → `iucr_name='_easydiffraction_constraint.*'`. - - `_joint_fit.*` → `iucr_name='_easydiffraction_joint_fit.*'`. - - `_sequential_fit.*`, `_sequential_fit_extract.*` → - `iucr_name='_easydiffraction_sequential_fit*.*'`. - - `_expt_type.*` → `iucr_name='_easydiffraction_experiment_type.*'`. - - `_excluded_region.*` → - `iucr_name='_easydiffraction_excluded_region.*'`. - - `_peak.*` → `iucr_name='_easydiffraction_peak.*'`. - - `_extinction.*` (project-side selectors and parameters) → - `iucr_name='_easydiffraction_extinction.*'` (the transformer in - P1.14 also dual-emits the coreCIF `_refine_ls.extinction_*` - triple). - - `_background.type` → - `iucr_name='_easydiffraction_background.type'`. - - `_sc_crystal_block.*` → - `iucr_name='_easydiffraction_sc_crystal_block.*'`. - - Bayesian-only `_fit_result.*` fields → - `iucr_name='_easydiffraction_fit_result.*'` (project extensions - per ADR §3.3). - - The non-IUCr-counterpart `_diffrn.ambient_magnetic_field` and - `_diffrn.ambient_electric_field` descriptors get - `iucr_name='_easydiffraction_diffrn.ambient_magnetic_field'` / - `…electric_field`. - - Default-save behaviour is unchanged for every touched descriptor — - only the IUCr-export emission picks up the new prefix. - - Commit: `Set iucr_name on project-extension descriptors`. - -- [x] **P1.8 — Rename `project.summary` → `project.report`, preserve - display methods** - - Files: `src/easydiffraction/project/project.py`, - `src/easydiffraction/summary/` (renamed / migrated), - `src/easydiffraction/report/` (new). - - **Preserve every live `Summary` method.** The existing `Summary` - class (at `src/easydiffraction/summary/summary.py`) has live - user-facing display methods that tutorials call: `show_report()`, - `show_project_info()`, `show_crystallographic_data()`, - `show_experimental_data()`, `show_fitting_details()`. These are not - placeholders and must not be removed. Move them verbatim onto the - new `Report` class (rename of the class only, with no behavioural - change), so `project.report.show_report()` and friends remain - available with the same signatures and output. - - **Drop only the placeholder CIF method.** The `as_cif()` method on - `Summary` returns a stub string and is the only placeholder being - removed. Its caller — the `summary.cif` write at `Project.save()` - (currently at `src/easydiffraction/project/project.py:464`-ish) — is - removed in the same commit. The real CIF emission for journal - submission lives in `Report.save()` (writes `reports/.cif`, - real implementation lands in P1.15) — the two are independent: - dropping `as_cif()` does not break any user-visible behaviour - because nothing meaningful was being written. - - Migration mechanics: - - Either rename the package directory - (`src/easydiffraction/summary/` → `src/easydiffraction/report/`) - and class (`Summary` → `Report`) in one move, **or** add a new - `src/easydiffraction/report/report.py` whose `Report` class - inherits / delegates to the migrated display methods. Pick the - approach that produces the smallest diff at review time; both keep - the display behaviour intact. - - Update `__init__.py` re-exports accordingly. - - `Project` gains a `report` property returning the `Report` instance; - the old `summary` property is removed. The `summary.cif` write call - in `Project.save()` is removed. - - `Project.save()` gains a `report: bool = False` keyword (no - behaviour yet beyond passing through to `Report.save()` when truthy; - the real `Report.save()` lands in P1.15). - - Tutorial / CLI call sites that invoke - `project.summary.show_report()` are updated in **P1.17**. - - Commit: `Replace project.summary with project.report facade`. - -- [x] **P1.9 — Amend `project-facade-and-persistence.md`** - - File: `docs/dev/adrs/accepted/project-facade-and-persistence.md`. - - Document the `project.report` facade slot, removal of - `project.summary`, removal of `summary.cif` from default saves, and - the new `reports/.cif` output path. - - Commit: - `Amend project-facade-and-persistence ADR for project.report`. - -- [x] **P1.10 — Amend `help-discoverability.md`** - - File: `docs/dev/adrs/accepted/help-discoverability.md`. - - Replace `project.summary.help()` with `project.report.help()` in the - help-surface enumeration. - - Commit: `Amend help-discoverability ADR for project.report`. - -- [x] **P1.11 — IUCr writer foundation + `data_global` content** - - New file: `src/easydiffraction/io/cif/iucr_writer.py`. - - Implement the multi-datablock orchestrator skeleton: a - `write_iucr_cif(project, path)` entry point that opens - `reports/.cif`, emits `data_global` first, then delegates - topology-specific blocks to subordinate writers (added in P1.12 / - P1.13). - - Emit `data_global` content per §2.3a of the ADR: - `_audit.creation_method` / `_audit.creation_date`, - `_computing.structure_refinement` (derived from the - `_easydiffraction_software` triple), - `_easydiffraction_software.{framework, calculator, minimizer}`, - `_journal.*` and `_publ_*` placeholders (written as `?`), - `_chemical_formula.*` derived from atom sites where possible. - - Apply the §2.4 formatting rules: blank line between categories, - `# ----
----` headers, 80-char wrap on long strings, - dotted DDLm form throughout, project extensions grouped at the end - of each block. - - Wire `Report.save()` (stubbed in P1.8) to call `write_iucr_cif`. - - Commit: `Add IUCr CIF writer with data_global block`. - -- [x] **P1.12 — Single-crystal block layout** - - Same file as P1.11; add `_write_sc_block` helper. - - Per-structure block emission per §2.3b: `_chemical_formula.*`, - `_cell.*`, `_space_group.*` + `_space_group_symop.*` loop, - `_diffrn.*`, `_diffrn_radiation_wavelength.*` (scalar / single-row - category for monochromatic per the wavelength transformer), - `_atom_site.*` + `_atom_site_aniso.*` loops with ADP single-tag - emission, `_refine_ls.*`, `_reflns.*`, the `_refln.*` loop per §2.3c - (`index_h/k/l`, `F_squared_meas`, `F_squared_calc`, - `F_squared_meas_su`, `include_status`). - - For SC the project-extension `_easydiffraction_extinction.*` block + - the dual `_refine_ls.extinction_*` triple are emitted via the - transformer (added in P1.14). - - Commit: `Emit single-crystal blocks in IUCr CIF writer`. - -- [x] **P1.13 — Powder Rietveld block layout (CWL + TOF)** - - Same writer file; add `_write_rietveld_blocks` helper. - - Emit `data__overall`, `data__phase_N` (one per - phase), `data__pwd_N` (one per pattern) per §2.3f / §2.3g / - §2.3h. - - Profile-data loop columns: CWL form uses `_pd_meas.2theta_scan`; TOF - form uses `_pd_meas.time_of_flight`. Other columns: - `_pd_meas.intensity_total`, `_pd_calc.intensity_total`, - `_pd_proc.intensity_bkg_calc`, `_pd_proc_ls.weight`. - - Powder reflections loop per §2.3d: - `_refln.{index_h/k/l, F_squared_meas, F_squared_calc, d_spacing}` - plus `_pd_refln.phase_id` for the powder phase identifier. - - Cross-block reference markers (`_pd_block_id`, - `_pd_block_diffractogram_id`) emitted with pipe-delimited - identifiers matching the §2.3 examples. - - Joint Rietveld and sequential fits emit one `_pwd_N` per pattern / - step inside the same file. - - Commit: `Emit powder Rietveld blocks in IUCr CIF writer`. - -- [x] **P1.14 — `IucrCategoryTransformer` subclasses** - - New file: `src/easydiffraction/io/cif/iucr_transformers.py`. - - Implement and register five transformers per §3 of the ADR: - - **Wavelength** — monochromatic scalar / single-row category; - multi-row loop when applicable. - - **TOF calibration** — four-row - `_pd_calib_d_to_tof.{id, coeff, power, coeff_su, diffractogram_id}` - loop with EasyDiffraction attribute names as `id` codes (`offset`, - `linear`, `quad`, `recip`) and powers 0, 1, 2, −1 respectively per - the §2.3h cif_pow.dic equation. - - **Excluded regions** — free-text rendering as - `_pd_proc.info_excluded_regions`. - - **Symmetry operations** — `_space_group_symop.*` loop derived from - the active space group. - - **Extinction** — dual emit `_easydiffraction_extinction.*` + the - coreCIF `_refine_ls.extinction_{method,coef,expression}` triple, - with the descriptive string built from the project's - `(type, model)` selectors per the ADR §3 mapping table. - - Wire transformers into the writer from P1.12 / P1.13 (the writer - asks each per-block category for its IUCr representation, which is - either a direct `iucr_name` rename or a transformer call). - - Commit: `Add IUCr category transformers for restructured emissions`. - -- [x] **P1.15 — Wire `Project.save(report=True)` end-to-end** - - File: `src/easydiffraction/project/project.py`, - `src/easydiffraction/report/report.py`. - - `Project.save(report=False)` continues to write the regular project - files. `Project.save(report=True)` additionally writes - `reports/.cif`. - - `Project.save(report=True, check=False)` is the default for the - report path; `check=True` is wired in P1.16. - - Make the `reports/` directory if absent; overwrite an existing - report file (no round-trip). - - Commit: `Wire report=True kwarg on Project.save`. - -- [x] **P1.16 — Submission-side validation via gemmi** - - New file: `src/easydiffraction/report/check.py`. - - Implement `Report.check()` using `gemmi.cif.read_doc` against the - shipped (or downloaded) `cif_core.dic` and `cif_pow.dic`. Skip - `_easydiffraction_*` from unknown-tag warnings. - - Wire `Project.save(report=True, check=True)` to run validation after - the write and surface any errors / warnings to the user. - - Verify gemmi version supports the validation calls in the project's - pinned env; if not, bump the constraint in `pyproject.toml` / - `pixi.toml` / `pixi.lock` (pre-approved per AGENTS.md §Architecture - because gemmi is already a named dependency). - - Commit: `Add Report.check() validation via gemmi`. - -- [x] **P1.17 — Update tutorials / CLI / docs references** - - Source files in `docs/docs/tutorials/*.py` and CLI commands in - `src/easydiffraction/cli/`. - - Replace every `project.summary.*` call site with the matching - `project.report.*` call: - - `project.summary.show_report()` → `project.report.show_report()` - (currently used by `docs/docs/tutorials/ed-3.py`, - `docs/docs/tutorials/ed-5.py`, `docs/docs/tutorials/ed-6.py`, - `docs/docs/tutorials/ed-8.py` — confirm the full list via - `git grep -n 'project\.summary'` at the start of the step and - update every match). - - Other `project.summary.*` accessors (`show_project_info`, - `show_crystallographic_data`, `show_experimental_data`, - `show_fitting_details`) — replace each call with the - `project.report.*` equivalent. - - `project.summary.help()` in any tutorial or doc page becomes - `project.report.help()`. - - Demonstrate `project.save(report=True)` in at least one tutorial - that finishes a fit; reference `reports/.cif` in the prose. - - Regenerate notebooks with `pixi run notebook-prepare` (per AGENTS.md - §Tutorials). - - Commit: `Update tutorials and CLI for project.report rename`. - -- [x] **P1.18 — Promote ADR to `accepted/`** - - Move `docs/dev/adrs/suggestions/iucr-cif-tag-alignment.md` to - `docs/dev/adrs/accepted/iucr-cif-tag-alignment.md`. - - Flip the front-matter `**Status:**` line from `Proposed` to - `Accepted`. Update the date to today (Phase 1 acceptance date). - - Update `docs/dev/adrs/index.md`: the row's status column changes - from `Suggestion` to `Accepted`, and the link target changes from - `suggestions/` to `accepted/`. - - Commit: `Promote iucr-cif-tag-alignment ADR to accepted`. - -- [x] **P1.19 — Reach Phase 1 review gate** - - No-code step. Mark every `[ ]` above as `[x]`; commit the plan-file - update alone. - - Commit: `Reach Phase 1 review gate`. - -## Test plan (Phase 2) - -Per AGENTS.md §Testing, every new module, class, and bug fix ships with -tests; unit tests mirror the source tree. Before running the -verification commands below, add or update: - -- [x] **`tests/unit/easydiffraction/io/cif/test_handler.py`** — - `CifHandler.iucr_name` resolver: explicit value used when set, - fallback to `names[0]` when unset. P1.1 surface. -- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_writer.py`** — - fixture-driven golden tests for each topology covered in §2.3 / - §2.2 worked examples: single-crystal (Example A), single- - experiment Rietveld CWL (Example B), joint Rietveld multi- - experiment (Example C), sequential TOF Rietveld (Example D). Each - golden compares the emitted file against a checked-in reference - and asserts: block names, block order, `data_global` content, - profile-data / reflections loop columns, project-extension - `_easydiffraction_*` grouping at end of block, 80-char wrap. - P1.11–P1.13 surface. -- [x] **`tests/unit/easydiffraction/io/cif/test_iucr_transformers.py`** - — per-transformer unit tests: wavelength scalar vs loop based on - multiplicity; TOF calibration loop with - `id = offset / linear / quad / recip` and powers 0, 1, 2, −1; - range-form excluded regions rendered to - `_pd_proc.info_excluded_regions`; `_space_group_symop.*` loop - derived from the active space group; extinction `(type, model)` → - `_refine_ls.extinction_method` descriptive string and - `_refine_ls.extinction_coef` value per the ADR §3 mapping table - (Becker-Coppens type 1 / type 2 / mixed, Zachariasen). P1.14 - surface. -- [x] **`tests/unit/easydiffraction/io/cif/test_serialize.py`** — update - to cover ADP single-tag emission: rows with `ADP_type='Biso'` / - `'Bani'` emit only the `B_*` family; rows with `ADP_type='Uiso'` / - `'Uani'` emit only the `U_*` family. P1.3 surface. -- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/atom_sites/test_default.py`** - — update for the casing fixes from P1.2: `_atom_site.ADP_type` - (uppercase ADP), `_atom_site.Wyckoff_symbol` (uppercase W, - "symbol"); legacy lowercase forms still loadable on read. -- [x] **`tests/unit/easydiffraction/datablocks/structure/categories/space_group/test_default.py`** - — update for `_space_group.name_H-M_alt` and - `_space_group.IT_coordinate_system_code` casing fixes (P1.2). -- [x] **`tests/unit/easydiffraction/analysis/categories/fit_result/test_lsq.py`** - — update for the new `_fit_result.*` fields from P1.4: each new - descriptor (`R_factor_all`, `wR_factor_all`, `R_factor_gt`, - `wR_factor_gt`, `prof_R_factor`, `prof_wR_factor`, - `prof_wR_expected`, `number_restraints`, `number_constraints`, - `shift_over_su_max`, `shift_over_su_mean`, `profile_function`, - `background_function`, `threshold_expression`, - `number_reflns_total`, `number_reflns_gt`) is read / written - round-trip; fields unset for inapplicable experiment families - remain `None`. -- [x] **`tests/unit/easydiffraction/report/test_report.py`** — new - `Report` class: `save()` writes `reports/.cif`; preserved - display methods (`show_report`, `show_project_info`, - `show_crystallographic_data`, `show_experimental_data`, - `show_fitting_details`) keep their existing behaviour (port the - relevant assertions from the current - `tests/unit/easydiffraction/summary/`-equivalent tests if any - exist; otherwise add coverage). P1.8 surface. -- [x] **`tests/unit/easydiffraction/report/test_check.py`** — - `Report.check()` validates the generated CIF against - `cif_core.dic` / `cif_pow.dic` via gemmi; surfaces unknown- tag - warnings for non-extension categories; ignores the - `_easydiffraction_*` namespace per the configured skip list. P1.16 - surface. -- [x] **`tests/unit/easydiffraction/project/test_project.py`** — update - / extend: `Project.save()` no longer writes `summary.cif`; - `Project.save(report=True)` writes `reports/.cif`; - `Project.save(report=True, check=True)` runs validation; the - `project.report` facade exposes `save`, `check`, and the preserved - display methods. P1.8, P1.15, P1.16 surface. -- [x] **`tests/unit/easydiffraction/analysis/test_analysis.py`** — - update for the project-extension `iucr_name` settings on - `_minimizer.*`, `_calculator.*`, `_fitting_mode.*` (P1.7): - default-save CIF tags unchanged; IUCr-export `iucr_name` resolves - to `_easydiffraction_*` prefix. -- [x] **Script / tutorial coverage.** Verify `pixi run script-tests` - exercises at least one tutorial that calls - `project.save(report=True)` and the resulting - `reports/.cif` is non-empty and gemmi-valid. If no - tutorial exercises this, extend the relevant tutorial source per - P1.17 and regenerate the notebook. - -Use `pixi run test-structure-check` to confirm the unit-test layout -mirrors the source tree per AGENTS.md §Testing. - -## Verification commands (Phase 2) - -Per AGENTS.md §Workflow, save any required check output with the -zsh-safe pattern. Variable names per-task: - -```sh -pixi run fix > /tmp/easydiffraction-fix.log 2>&1; fix_exit_code=$?; tail -n 200 /tmp/easydiffraction-fix.log; exit $fix_exit_code -pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code -pixi run unit-tests > /tmp/easydiffraction-unit-tests.log 2>&1; unit_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-unit-tests.log; exit $unit_tests_exit_code -pixi run integration-tests > /tmp/easydiffraction-integration-tests.log 2>&1; integration_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-integration-tests.log; exit $integration_tests_exit_code -pixi run script-tests > /tmp/easydiffraction-script-tests.log 2>&1; script_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-script-tests.log; exit $script_tests_exit_code -``` - -Run in order; each must complete clean before the next. The -`pixi run script-tests` pass may surface tutorial path collisions or -stale tutorials — apply the tutorial-source fix from AGENTS.md §Workflow -("If `pixi run script-tests` fails because two tutorials write to the -same project directory…") rather than deleting project output. Benchmark -CSVs under `docs/dev/benchmarking/` produced by `pixi run script-tests` -are untracked verification artifacts; do not stage them. - -## Suggested Pull Request - -**Title:** -`[scope] Align CIF tags with IUCr dictionaries and add journal-submission export` - -**Description:** - -EasyDiffraction now writes day-to-day project CIFs in a form that -matches the IUCr core and powder dictionaries where it makes sense, -while keeping the experiment-side names friendly for users who edit CIFs -by hand. Structure CIFs adopt the dictionary casing -(`_atom_site.ADP_type`, `_atom_site.Wyckoff_symbol`, -`_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code`), -and atomic displacement parameters are written using a single family per -row (`B_*` or `U_*`) based on `_atom_site.ADP_type`. Analysis CIFs gain -a richer set of fit-output statistics — the standard Rietveld R-factor -family, restraint and constraint counts, shift / σ diagnostics, profile -and background function descriptors — all under the existing -topology-neutral `_fit_result.*` category, with item names matching IUCr -casing so they are immediately recognisable to anyone familiar with -`_refine_ls.*` / `_pd_proc_ls.*` from publications. - -A new `project.report` facade replaces the previously empty -`project.summary` placeholder. Calling `project.save(report=True)` (or -`project.report.save()`) generates a single journal-submission CIF at -`reports/.cif` — one multi- datablock file ready to upload to -IUCr journals, with the publication-metadata `data_global` block holding -`?` placeholders the user fills in before submission. The new -`project.report.check()` runs the generated file through `gemmi` against -the IUCr dictionaries to surface any tag, category, or type issues -before the upload. - -The PR also amends four accepted ADRs (`analysis-cif-fit-state`, -`minimizer-input-output-split`, `project-facade-and-persistence`, -`help-discoverability`) to reflect the new facade and field set, and -promotes the `iucr-cif-tag-alignment` ADR to `accepted/`. - -**Scope label:** `[analysis]` or `[io]` — pick whichever the maintainers -prefer for the IUCr-export work; the field renames in `analysis.cif` -lean `[analysis]`, the new writer leans `[io]`. diff --git a/docs/dev/plans/project-summary-rendering.md b/docs/dev/plans/project-summary-rendering.md deleted file mode 100644 index dcd812972..000000000 --- a/docs/dev/plans/project-summary-rendering.md +++ /dev/null @@ -1,1346 +0,0 @@ -# Plan: Project Summary Rendering — migration to single-style + pgfplots + DisplayHandler - -Implementation plan for the -[`project-summary-rendering`](../adrs/accepted/project-summary-rendering.md) -ADR. Follows [`AGENTS.md`](../../../AGENTS.md) — no deliberate -exceptions to those instructions. - -> **Context for this plan.** The branch `project-summary-rendering` -> already carries an end-to-end Phase 1 implementation of an earlier -> version of the same ADR (PR-ready as of commit -> `f354fa435 Reach Phase 1 review gate`). That earlier implementation -> supported two LaTeX styles (`iucr` + `revtex`), used `kaleido` + a -> Chrome bootstrap to render fit-quality figures, and had no -> `DisplayHandler` for descriptor display metadata. The ADR has since -> been substantially rewritten (single `iucrjournals` style only; -> `pgfplots` with external CSV in place of `kaleido`; new -> `DisplayHandler` value object; new ASCII units vocabulary aligned to -> CIF DDLm `_units.code`; MathJax bundled under `html_offline=True`; -> descriptor-driven `fit_data.x` payload in `ReportDataContext`). -> -> This plan is the **migration delta**: the diff between the committed -> implementation and the rewritten ADR. It does **not** re-derive the -> surfaces already shipped (config category, per-format methods, -> `analysis.software`, `project.publication`, and CLI report saving via -> `fit`); those stay as-is except for the later approved removal of the -> standalone `save` / `save-report` commands. Every step below either -> deletes or replaces something the previous implementation introduced, -> or adds a new surface the rewritten ADR requires. - -## ADR cross-reference - -- **Primary ADR:** - [`project-summary-rendering.md`](../adrs/accepted/project-summary-rendering.md) - (Accepted; ADR review cycle closed at review 5 sentinel). -- The ADR's "ADRs amended by this ADR" section is unchanged by this - migration — the amendments to - [`iucr-cif-tag-alignment`](../adrs/accepted/iucr-cif-tag-alignment.md), - [`analysis-cif-fit-state`](../adrs/accepted/analysis-cif-fit-state.md), - [`project-facade-and-persistence`](../adrs/accepted/project-facade-and-persistence.md), - and the suggested - [`python-cif-category-correspondence`](../adrs/suggestions/python-cif-category-correspondence.md) - were applied in the earlier P1 walk and remain valid. -- No new ADR is required for this migration. The rewritten ADR is the - authoritative reference. - -## Branch and PR - -- **Branch:** `project-summary-rendering` (already checked out; carries - the earlier implementation commits). -- **PR target:** `develop`. -- Do not push the branch until both Phase 1 and Phase 2 review cycles - close. - -## Decisions already made (in the ADR) - -These are settled by the accepted, rewritten ADR — this plan does not -re-litigate them: - -- **Five-field config, no `style`** (§1.1, §1.3): the `Report` - `CategoryItem` carries exactly five persisted descriptors — `cif`, - `html`, `tex`, `pdf`, `html_offline` — written as `_report.*` in - `project.cif`. No `style` field, no `ReportStyleEnum`, no `--style` - CLI flag. Multi-style support is deferred to a follow-up ADR (see ADR - "Deferred Work"). -- **Single LaTeX style — `iucrjournals` hardcoded** (§3.2): the LaTeX - renderer emits one document class (`iucrjournals`). The vendored TeX - bundle drops from 12 files (previous design) to 2: `iucrjournals.cls` - and `harvard.sty`, both CC0 1.0 from the IUCr upstream. -- **No `kaleido`, no `chromium`** (§3.3): each fit-quality figure is a - **standalone `pgfplots` `.tex` document** - (`reports/tex/data/fit_.tex`, reading its sibling - `fit_.csv`), compiled independently to `fit_.pdf` - and pulled into the main report via `\includegraphics` — no inline - pgfplots in `.tex`, and per-figure compiles isolate the TeX - memory pool. The fit-quality figure uses the Plotly geometry, colors, - legend structure, grid colors, and line widths where `pgfplots` can - support them without exceeding TeX memory. The PDF figure deliberately - does **not** include the background curve or measured error bars, and - it keeps every measured point with the small pgfplots marker size from - the accepted design. The TeX engine (Tectonic / TeX Live / MiKTeX) - supplies `pgfplots` + `standalone` + TikZ deps from CTAN or its - default sets. -- **`DisplayHandler` value object** (§1.5): new - `@dataclass(frozen=True, slots=True)` at - `src/easydiffraction/core/display_handler.py` carrying four optional - fields — `display_name`, `display_units`, `latex_name`, `latex_units`. - Renderers consult it via a per-context fallback chain (LaTeX context → - `latex_*` fields, HTML context → `display_*` fields, GUI/terminal → - `display_*` fields), each falling back to the descriptor's plain - `name` / `units`. **Table-rendering paths MUST read through the - resolution chain, not the raw `descriptor.units` field**, because - `units=` now holds ASCII CIF DDLm codes. -- **Units vocabulary aligned to `_units.code`** (§1.5): every `units=` - string on a descriptor is the ASCII value the CIF DDLm dictionary - defines verbatim (`angstroms`, `angstrom_squared`, `degrees`, - `kelvins`, `kilopascals`, `microseconds`, `dalton`, `megagray`, - `reciprocal_angstroms`, `reciprocal_angstrom_squared`, `none`). The - single project-internal code in scope is `degrees_squared` (no - `_units.code` round-trip). A new `units_vocabulary.py` module - enumerates every valid code for a declaration-time validator. The - Unicode-symbol form (`Ų`, `°`, `Å`) moves into `display_units`; the - LaTeX form (`\AA$^2$`, `$\deg$`, `\AA`) into `latex_units`. -- **MathJax bundling under `html_offline`** (§2): `html_offline=True` - inline-bundles **both** Plotly (~3 MB, `include_plotlyjs=True`) and - the vendored MathJax `tex-mml-chtml.js` (~1.5 MB, Apache-2.0). When - `html_offline=False`, both load from CDN. No new Python dependency; - MathJax is a static JS asset under - `src/easydiffraction/report/templates/html/vendor/` packaged by - hatchling. -- **Descriptor-driven `fit_data` shape** (§6): `data_context()`'s - `experiments[i].fit_data` payload carries an `x` sub-dict (values + - descriptor `name`, `units`, `display_name`, `latex_name`, - `display_units`, `latex_units` resolved at builder time) and a - `series` sub-dict (`meas`, `calc`, `diff`, optional `bkg`, each with - `values`, optional `su`, `label`). One descriptor path, two renderers - (Plotly for HTML, pgfplots CSV for TeX), all experiment types — Bragg - powder `two_theta`, TOF `time_of_flight`, total-scattering `r`, future - `q`, … -- **CIF persistence unchanged in shape** (§1.3): the existing - `category_owner_to_cif` walker continues to emit `_report.*`; the - read-side hook in `project_config_from_cif` continues to restore the - five fields. Removing `_report.style` from the write surface is the - only persistence change required. - -## Open questions to resolve during implementation - -- **`DisplayHandler` attachment point.** The ADR says the handler - attaches to the descriptor as an optional slot. Confirm during P1 - whether the right surface is the base `Descriptor` class (every - descriptor type inherits the slot) or the more specific `Parameter` / - `StringDescriptor` subclasses. Default assumption: base `Descriptor` — - uniform across all descriptor kinds. -- **MathJax bundle version.** Pick a specific MathJax 3.x release for - `tex-mml-chtml.js` and pin its source URL in `LICENSES.md` for - traceability. Assume the latest 3.x release tagged on jsdelivr at - vendoring time. -- **`reports/tex/data/fit_.csv` schema.** The CSV emitter - writes one row per data point with columns `x`, `meas`, optional - `meas_su`, `calc`, `diff`. Background is not emitted to the pgfplots - CSV because the PDF figure does not plot it. Confirm `figure.tex.j2`'s - column references match this schema exactly during P1. -- **Style-bundle cleanup vs. wheel size.** The 10 REVTeX files dropped - from the bundle reduce the wheel by ~350 KB. The Phase 2 verification - step covers the actual `pixi run dist-build` check - (`iucrjournals.cls` + `harvard.sty` still ship; MathJax vendored - bundle included); Phase 1 only edits - `[tool.hatch.build.targets.wheel]` packaging rules if hatchling's - defaults don't pick the files up. -- **Tutorial coverage.** The two tutorial files already modified in the - worktree (`ed-3.py`, `ed-14.py`) reference the old `style=` API; they - need re-editing or reverting in P1.17 once the new surface is in - place. - -## Concrete files likely to change - -**Surface shrink — drop `style` everywhere:** - -- `src/easydiffraction/report/enums.py` (existing — delete - `ReportStyleEnum`; keep only `ReportFormatEnum`). -- `src/easydiffraction/report/__init__.py` (existing — remove - `ReportStyleEnum` re-export). -- `src/easydiffraction/project/categories/report/default.py` (existing — - delete the `style` `StringDescriptor`; drop the `style=` parameter - from `save_tex()`, `save_pdf()`, any `Report.save()` dispatch; update - docstrings). -- `src/easydiffraction/__main__.py` (existing — remove standalone - report-export CLI options; report saving is driven by persisted - `_report.*` flags during `fit`). -- `src/easydiffraction/report/tex_renderer.py` (existing — remove style - dispatch; emit a single template). -- `src/easydiffraction/io/cif/serialize.py` (existing — no code change - expected; the walker emits whatever descriptors are declared, so - removing the `style` descriptor drops the `_report.style` row - automatically). - -**Style-bundle shrink (12 → 2):** - -- `src/easydiffraction/report/templates/tex/styles/` — delete: - `revtex4-2.cls`, `ltxgrid.sty`, `ltxutil.sty`, `ltxfront.sty`, - `ltxdocext.sty`, `revsymb4-2.sty`, `aps4-2.rtx`, `aps10pt4-2.rtx`, - `aps11pt4-2.rtx`, `aps12pt4-2.rtx`. Keep: `iucrjournals.cls`, - `harvard.sty`. -- `src/easydiffraction/report/templates/tex/styles/LICENSES.md` - (existing — rewrite to cover only the two CC0 1.0 files; remove the - LPPL 1.3c REVTeX section). -- `THIRD_PARTY_LICENSES.md` (existing at repo root — shrink index to one - entry). -- `src/easydiffraction/report/templates/tex/iucr.tex.j2` (existing — - rename to a single canonical template name, e.g. `report.tex.j2`). -- `src/easydiffraction/report/templates/tex/revtex.tex.j2` (existing — - delete). - -**Drop `kaleido`:** - -- `pyproject.toml` (existing — remove `kaleido` from the runtime - dependency list). -- `pixi.lock` (regenerated). -- `src/easydiffraction/report/tex_renderer.py` (existing — remove - kaleido-based PDF figure emission and `figures/` output; replace with - pgfplots CSV emission). -- `docs/docs/user-guide/analysis-workflow/report.md` (existing — drop - the kaleido bootstrap section). - -**Add `DisplayHandler`:** - -- `src/easydiffraction/core/display_handler.py` (new — frozen dataclass - with four `str | None` fields). -- `src/easydiffraction/core/__init__.py` (existing — export - `DisplayHandler` alongside `CifHandler` etc., per AGENTS.md - `__init__.py` rule). -- `src/easydiffraction/core/descriptor.py` (or `parameter.py` / - `base_descriptor.py` — whichever owns the descriptor base) (existing — - add an optional `display_handler: DisplayHandler | None = None` slot; - define resolution helpers `resolve_display_name(context)` / - `resolve_display_units(context)` per the ADR's per-context fallback - chain). - -**Units vocabulary sweep:** - -- `src/easydiffraction/core/units_vocabulary.py` (new — enumerate every - valid `units=` code; raise `ValueError` with the offending code on - validation failure; called from the descriptor base class at - construction time). -- Every descriptor declaration in `src/easydiffraction/` that currently - passes a Unicode units string (`'Ų'`, `'°'`, `'Å'`, …) — rewrite to - use the ASCII DDLm code and attach a `DisplayHandler` with the Unicode - / LaTeX forms. Concrete file list resolved during P1.7's sweep; expect - ≥20 sites across `datablocks/structure/`, `datablocks/experiment/`, - `analysis/categories/`. - -**Table-rendering migration:** - -- `src/easydiffraction/project/categories/report/default.py` (existing — - every `show_*()` method that builds a unit column reads through - `resolve_display_units(...)` instead of `descriptor.units` directly). -- `src/easydiffraction/report/html_renderer.py` (existing — same - migration for Jinja context inputs). -- `src/easydiffraction/report/tex_renderer.py` (existing — same - migration for the TeX context). -- `src/easydiffraction/report/data_context.py` (existing — every label / - unit string baked into the context is resolved per-context here so - renderers don't re-derive). - -**MathJax bundling:** - -- `src/easydiffraction/report/templates/html/vendor/mathjax-tex-mml-chtml.js` - (new — vendored static asset, Apache-2.0). -- `src/easydiffraction/report/templates/html/vendor/LICENSES.md` (new — - Apache-2.0 text + upstream URL + version). -- `THIRD_PARTY_LICENSES.md` (existing at repo root — extend index with - MathJax entry alongside the IUCr TeX bundle). -- `src/easydiffraction/report/templates/html/report.html.j2` (existing — - switch the MathJax ``. - - `False`: - ``. - - When `html_offline=True`, the renderer copies the vendored file next - to the emitted `.html` so the relative - ` + diff --git a/src/easydiffraction/display/structure/viewing.py b/src/easydiffraction/display/structure/viewing.py new file mode 100644 index 000000000..7548f2ef8 --- /dev/null +++ b/src/easydiffraction/display/structure/viewing.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Viewer facade and engine factory for the structure view.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from easydiffraction.display.base import RendererBase +from easydiffraction.display.base import RendererFactoryBase +from easydiffraction.display.structure.enums import ViewerEngineEnum +from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer +from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer + +if TYPE_CHECKING: + from easydiffraction.display.structure.scene import StructureScene + + +class ViewerFactory(RendererFactoryBase): + """Factory for structure-view renderer engines.""" + + @classmethod + def _registry(cls) -> dict: + return { + ViewerEngineEnum.ASCII.value: { + 'description': ViewerEngineEnum.ASCII.description(), + 'class': AsciiStructureRenderer, + }, + ViewerEngineEnum.THREEJS.value: { + 'description': ViewerEngineEnum.THREEJS.description(), + 'class': ThreeJsStructureRenderer, + }, + } + + +class Viewer(RendererBase): + """Switchable facade that draws a scene with the active engine.""" + + @classmethod + def _factory(cls) -> type[RendererFactoryBase]: + """Return the structure-view engine factory.""" + return ViewerFactory + + @classmethod + def _default_engine(cls) -> str: + """Return the default engine name (Three.js).""" + return ViewerEngineEnum.default().value + + def show_config(self) -> None: + """Display the active structure-view engine.""" + self.show_current_engine() + + def render(self, scene: StructureScene, *, features: frozenset[str]) -> str: + """ + Draw the scene with the active engine. + + Parameters + ---------- + scene : StructureScene + The renderer-neutral primitives to draw. + features : frozenset[str] + The content-resolved feature set from the display facade. + + Returns + ------- + str + ASCII text or an HTML document, depending on the active + engine. + """ + return self._backend.render(scene, features=features) + + def supported_features(self) -> frozenset[str]: + """Return the feature names the active engine can draw.""" + return self._backend.supported_features() diff --git a/src/easydiffraction/display/tablers/base.py b/src/easydiffraction/display/tablers/base.py index 0bc2bddfb..decb2282f 100644 --- a/src/easydiffraction/display/tablers/base.py +++ b/src/easydiffraction/display/tablers/base.py @@ -15,6 +15,8 @@ from IPython import get_ipython from rich.color import Color +from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR +from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR from easydiffraction.utils._vendored.theme_detect import is_dark @@ -96,7 +98,9 @@ def _rich_border_color(self) -> str: @property def _pandas_border_color(self) -> str: - return self._rich_to_hex(self._rich_border_color) + if self._is_dark_theme(): + return DARK_AXIS_FRAME_COLOR + return LIGHT_AXIS_FRAME_COLOR @abstractmethod def build_renderable( @@ -127,6 +131,7 @@ def render( alignments: object, df: object, display_handle: object | None = None, + width: int | None = None, ) -> object: """ Render the provided DataFrame with backend-specific styling. @@ -141,6 +146,9 @@ def render( display_handle : object | None, default=None Optional environment-specific handle to enable in-place updates. + width : int | None, default=None + Optional target table width. Honored by fixed-width backends + (e.g. Rich); ignored by reflowing ones (e.g. HTML). Returns ------- diff --git a/src/easydiffraction/display/tablers/pandas.py b/src/easydiffraction/display/tablers/pandas.py index 823c2cb09..25a013902 100644 --- a/src/easydiffraction/display/tablers/pandas.py +++ b/src/easydiffraction/display/tablers/pandas.py @@ -14,15 +14,27 @@ import re from easydiffraction.display.tablers.base import TableBackendBase +from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR +from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR +from easydiffraction.display.theme import TABLE_AXIS_FRAME_CSS_VAR from easydiffraction.utils.environment import can_use_ipython_display from easydiffraction.utils.logging import log _RICH_COLOR_RE = re.compile(r'\[(\w+)\](.*?)\[/\1\]') +PANDAS_TABLE_THEME_CLASS = 'ed-themed-table' +PANDAS_AXIS_FRAME_COLOR = f'var({TABLE_AXIS_FRAME_CSS_VAR}, {LIGHT_AXIS_FRAME_COLOR})' class PandasTableBackend(TableBackendBase): """Render tables using the pandas Styler in Jupyter environments.""" + def _table_attributes(self) -> str: + """Return HTML table attributes for themed pandas tables.""" + return ( + f'class="dataframe {PANDAS_TABLE_THEME_CLASS}" ' + f'style="{TABLE_AXIS_FRAME_CSS_VAR}: {self._pandas_border_color};"' + ) + @staticmethod def _build_base_styles(color: str) -> list[dict]: """ @@ -172,7 +184,7 @@ def _apply_styling(self, df: object, alignments: object, color: str) -> object: styler = df.style.format(precision=self.FLOAT_PRECISION) if color_styles is not None: styler = styler.apply(lambda _: color_styles, axis=None) - styler = styler.set_table_attributes('class="dataframe"') # For mkdocs-jupyter + styler = styler.set_table_attributes(self._table_attributes()) styler = styler.set_table_styles(table_styles + header_alignment_styles) for column, align in zip(df.columns, alignments, strict=False): @@ -182,8 +194,7 @@ def _apply_styling(self, df: object, alignments: object, color: str) -> object: ) return styler - @staticmethod - def _update_display(styler: object, display_handle: object) -> None: + def _update_display(self, styler: object, display_handle: object) -> None: """ Single, consistent update path for Jupyter. @@ -203,7 +214,7 @@ def _update_display(styler: object, display_handle: object) -> None: # IPython DisplayHandle path if can_use_ipython_display(display_handle) and HTML is not None: try: - html = styler.to_html() + html = self._themed_html(styler) display_handle.update(HTML(html)) except (TypeError, ValueError, AttributeError, RuntimeError, OSError) as err: log.debug(f'Pandas DisplayHandle update failed: {err!r}') @@ -215,13 +226,17 @@ def _update_display(styler: object, display_handle: object) -> None: pass # Normal display - display(styler) + if HTML is not None: + display(HTML(self._themed_html(styler))) + else: + display(styler) def render( self, alignments: object, df: object, display_handle: object | None = None, + width: int | None = None, ) -> object: """ Render a styled DataFrame. @@ -235,12 +250,16 @@ def render( display_handle : object | None, default=None Optional IPython DisplayHandle to update an existing output area in place when running in Jupyter. + width : int | None, default=None + Ignored. HTML tables reflow to the available width, so no + fixed table width is applied. Returns ------- object Backend-defined return value (commonly ``None``). """ + del width styler = self._build_styler(alignments, df) self._update_display(styler, display_handle) @@ -266,7 +285,7 @@ def build_renderable( HTML string representation of the styled table. """ styler = self._build_styler(alignments, df) - return styler.to_html() + return self._themed_html(styler) def _build_styler( self, @@ -274,5 +293,84 @@ def _build_styler( df: object, ) -> object: """Return a configured pandas Styler for the provided table.""" - color = self._pandas_border_color + color = PANDAS_AXIS_FRAME_COLOR return self._apply_styling(df, alignments, color) + + @classmethod + def _themed_html(cls, styler: object) -> str: + """Return styled table HTML plus the theme-sync script.""" + return styler.to_html() + cls._theme_sync_post_script() + + @staticmethod + def _theme_sync_post_script() -> str: + """Return client-side code for host table theme changes.""" + return f""" + +""" diff --git a/src/easydiffraction/display/tablers/rich.py b/src/easydiffraction/display/tablers/rich.py index e23334be3..ceeb8dd9e 100644 --- a/src/easydiffraction/display/tablers/rich.py +++ b/src/easydiffraction/display/tablers/rich.py @@ -157,6 +157,7 @@ def render( alignments: object, df: object, display_handle: object = None, + width: int | None = None, ) -> object: """ Render a styled table using Rich. @@ -169,6 +170,9 @@ def render( Index-aware DataFrame to render. display_handle : object, default=None Optional environment handle for in-place updates. + width : int | None, default=None + Optional target table width. When set, the table is sized to + this width so long cells wrap to fit the terminal. Returns ------- @@ -176,4 +180,6 @@ def render( Backend-defined return value (commonly ``None``). """ table = self.build_renderable(alignments, df) + if width is not None: + table.width = width self._update_display(table, display_handle) diff --git a/src/easydiffraction/display/tables.py b/src/easydiffraction/display/tables.py index 574d245a0..b6d4697fc 100644 --- a/src/easydiffraction/display/tables.py +++ b/src/easydiffraction/display/tables.py @@ -116,7 +116,12 @@ def build_renderable(self, df: object) -> object: alignments, prepared_df = self._prepare_dataframe(df) return self._backend.build_renderable(alignments, prepared_df) - def render(self, df: object, display_handle: object | None = None) -> object: + def render( + self, + df: object, + display_handle: object | None = None, + width: int | None = None, + ) -> object: """ Render a DataFrame as a table using the active backend. @@ -129,6 +134,9 @@ def render(self, df: object, display_handle: object | None = None) -> object: Optional environment-specific handle used to update an existing output area in-place (e.g., an IPython DisplayHandle or a terminal live handle). + width : int | None, default=None + Optional target table width passed to the backend. Honored + by fixed-width backends (Rich); ignored by HTML. Returns ------- @@ -136,7 +144,7 @@ def render(self, df: object, display_handle: object | None = None) -> object: Backend-specific return value (usually ``None``). """ alignments, prepared_df = self._prepare_dataframe(df) - return self._backend.render(alignments, prepared_df, display_handle) + return self._backend.render(alignments, prepared_df, display_handle, width) class TableRendererFactory(RendererFactoryBase): diff --git a/src/easydiffraction/display/theme.py b/src/easydiffraction/display/theme.py new file mode 100644 index 000000000..727dbf36f --- /dev/null +++ b/src/easydiffraction/display/theme.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Shared display theme colors for plots and notebook tables.""" + +from __future__ import annotations + +from dataclasses import dataclass + +LIGHT_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)' +DARK_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)' +LIGHT_FOREGROUND_COLOR = '#222222' +DARK_FOREGROUND_COLOR = '#e6e8ee' +LIGHT_AXIS_FRAME_COLOR = '#e0e0e0' +DARK_AXIS_FRAME_COLOR = '#333' +LIGHT_INNER_TICK_GRID_COLOR = '#f2f2f2' +DARK_INNER_TICK_GRID_COLOR = '#1c1c1c' +LIGHT_HOVER_BACKGROUND_COLOR = '#ffffff' +DARK_HOVER_BACKGROUND_COLOR = '#212121' +# Legend background mirrors the opaque theme base surface at 50% opacity +LIGHT_LEGEND_BACKGROUND_COLOR = 'rgba(255, 255, 255, 0.5)' +DARK_LEGEND_BACKGROUND_COLOR = 'rgba(33, 33, 33, 0.5)' +TABLE_AXIS_FRAME_CSS_VAR = '--ed-axis-frame-color' + + +@dataclass(frozen=True) +class DisplayThemeColors: + """ + Theme colors shared by interactive display outputs. + + Attributes + ---------- + background : str + Plot or output background color. + foreground : str + Primary text color. + axis_frame : str + Axis rectangle and table border color. + inner_tick_grid : str + Inner Plotly tick-grid color. + hover_background : str + Plotly hover label background color. + legend_background : str + Plotly legend background color. + """ + + background: str + foreground: str + axis_frame: str + inner_tick_grid: str + hover_background: str + legend_background: str + + +LIGHT_THEME_COLORS = DisplayThemeColors( + background=LIGHT_BACKGROUND_COLOR, + foreground=LIGHT_FOREGROUND_COLOR, + axis_frame=LIGHT_AXIS_FRAME_COLOR, + inner_tick_grid=LIGHT_INNER_TICK_GRID_COLOR, + hover_background=LIGHT_HOVER_BACKGROUND_COLOR, + legend_background=LIGHT_LEGEND_BACKGROUND_COLOR, +) +DARK_THEME_COLORS = DisplayThemeColors( + background=DARK_BACKGROUND_COLOR, + foreground=DARK_FOREGROUND_COLOR, + axis_frame=DARK_AXIS_FRAME_COLOR, + inner_tick_grid=DARK_INNER_TICK_GRID_COLOR, + hover_background=DARK_HOVER_BACKGROUND_COLOR, + legend_background=DARK_LEGEND_BACKGROUND_COLOR, +) + + +def display_theme_colors(*, is_dark_theme: bool) -> DisplayThemeColors: + """ + Return the display colors for the requested theme. + + Parameters + ---------- + is_dark_theme : bool + Whether to return the dark theme colors. + + Returns + ------- + DisplayThemeColors + Shared colors for the requested theme. + """ + if is_dark_theme: + return DARK_THEME_COLORS + return LIGHT_THEME_COLORS + + +def display_theme_colors_for_template(template: str) -> DisplayThemeColors | None: + """ + Return display colors for a Plotly template name. + + Parameters + ---------- + template : str + Plotly template name. + + Returns + ------- + DisplayThemeColors | None + Theme colors for known Plotly templates, otherwise ``None``. + """ + if template == 'plotly_white': + return LIGHT_THEME_COLORS + if template == 'plotly_dark': + return DARK_THEME_COLORS + return None diff --git a/src/easydiffraction/io/cif/iucr_writer.py b/src/easydiffraction/io/cif/iucr_writer.py index 1ee4f4f97..b0d5d9513 100644 --- a/src/easydiffraction/io/cif/iucr_writer.py +++ b/src/easydiffraction/io/cif/iucr_writer.py @@ -23,54 +23,6 @@ _TEXT_WRAP_WIDTH = 80 _ITEM_WIDTH = 38 -_JOURNAL_ITEMS = ( - ('_journal.name_full', 'name_full'), - ('_journal.year', 'year'), - ('_journal.volume', 'volume'), - ('_journal.issue', 'issue'), - ('_journal.page_first', 'page_first'), - ('_journal.page_last', 'page_last'), - ('_journal.paper_category', 'paper_category'), - ('_journal.paper_DOI', 'paper_doi'), - ('_journal.coden_ASTM', 'coden_astm'), - ('_journal.suppl_publ_number', 'suppl_publ_number'), -) - -_JOURNAL_DATE_ITEMS = ( - ('_journal_date.accepted', 'accepted'), - ('_journal_date.from_coeditor', 'from_coeditor'), - ('_journal_date.printers_final', 'printers_final'), -) - -_JOURNAL_COEDITOR_ITEMS = ( - ('_journal_coeditor.code', 'code'), - ('_journal_coeditor.name', 'name'), - ('_journal_coeditor.notes', 'notes'), -) - -_PUBL_CONTACT_AUTHOR_ITEMS = ( - ('_publ_contact_author.name', 'name'), - ('_publ_contact_author.address', 'address'), - ('_publ_contact_author.email', 'email'), - ('_publ_contact_author.phone', 'phone'), - ('_publ_contact_author.id_ORCID', 'id_orcid'), - ('_publ_contact_author.id_IUCr', 'id_iucr'), -) - -_PUBL_AUTHOR_ITEMS = ( - ('_publ_author.name', 'name'), - ('_publ_author.address', 'address'), - ('_publ_author.footnote', 'footnote'), - ('_publ_author.id_ORCID', 'id_orcid'), - ('_publ_author.id_IUCr', 'id_iucr'), -) -_PUBL_AUTHOR_TAGS = tuple(tag for tag, _ in _PUBL_AUTHOR_ITEMS) - -_PUBL_BODY_ITEMS = ( - ('_publ_body.title', 'title'), - ('_publ_body.contents', 'contents'), -) - _PACKAGE_BY_ENGINE = { 'cryspy': 'cryspy', 'crysfml': 'crysfml', @@ -133,10 +85,11 @@ def iucr_report_path( def _render_iucr_cif(project: object) -> str: """Render all IUCr CIF blocks for *project*.""" + used_block_names = {'global'} blocks = [_write_global_block(project)] - blocks.extend(_write_sc_blocks(project)) - blocks.extend(_write_rietveld_blocks(project)) - return f'\n{_BLOCK_SEPARATOR}\n'.join(blocks) + '\n' + blocks.extend(_write_sc_blocks(project, used_block_names)) + blocks.extend(_write_rietveld_blocks(project, used_block_names)) + return f'\n\n{_BLOCK_SEPARATOR}\n'.join(blocks) + '\n' def _write_global_block(project: object) -> str: @@ -144,7 +97,6 @@ def _write_global_block(project: object) -> str: lines = ['data_global'] _write_audit_section(lines) _write_computing_section(lines, project) - _write_publication_sections(lines, project) _write_formula_section(lines, project) return '\n'.join(lines) @@ -176,45 +128,6 @@ def _write_computing_section(lines: list[str], project: object) -> None: _write_item(lines, '_easydiffraction_software.fit_datetime', fit_datetime) -def _write_publication_sections(lines: list[str], project: object) -> None: - """ - Append publication metadata from the project publication owner. - """ - publication = getattr(project, 'publication', None) - _write_publication_item_section( - lines, - 'Journal', - getattr(publication, 'journal', None), - _JOURNAL_ITEMS, - ) - _write_publication_item_section( - lines, - 'Journal dates', - getattr(publication, 'journal_date', None), - _JOURNAL_DATE_ITEMS, - ) - _write_publication_item_section( - lines, - 'Journal coeditor', - getattr(publication, 'journal_coeditor', None), - _JOURNAL_COEDITOR_ITEMS, - ) - _write_publication_item_section( - lines, - 'Publication contact author', - getattr(publication, 'contact_author', None), - _PUBL_CONTACT_AUTHOR_ITEMS, - ) - - _section(lines, 'Publication authors') - _write_loop(lines, _PUBL_AUTHOR_TAGS, _publication_author_rows(publication)) - - _write_publication_body_section( - lines, - getattr(publication, 'body', None), - ) - - def _write_formula_section(lines: list[str], project: object) -> None: """Append chemical-formula summary metadata.""" formula = _chemical_formula_values(project) @@ -233,12 +146,16 @@ def _write_chemical_formula_section( _write_item(lines, '_chemical_formula.IUPAC', formula.iupac) -def _write_sc_blocks(project: object) -> list[str]: +def _write_sc_blocks(project: object, used_block_names: set[str]) -> list[str]: """Render single-crystal structure/experiment blocks.""" blocks: list[str] = [] for experiment in _single_crystal_experiments(project): structure = _linked_structure(project, experiment) - blocks.append(_write_sc_block(project, structure, experiment)) + block_name = _unique_block_name( + getattr(structure, 'name', None) or 'I', + used_block_names, + ) + blocks.append(_write_sc_block(project, structure, experiment, block_name)) return blocks @@ -246,9 +163,9 @@ def _write_sc_block( project: object, structure: object, experiment: object, + block_name: str, ) -> str: """Render one single-crystal data block.""" - block_name = _block_name(getattr(structure, 'name', None) or 'I') lines = [f'data_{block_name}'] _write_chemical_formula_section(lines, _structure_formula_values(structure)) _write_cell_section(lines, structure) @@ -474,16 +391,17 @@ def _write_sc_project_extensions(lines: list[str], experiment: object) -> None: _write_item(lines, tag, value) -def _write_rietveld_blocks(project: object) -> list[str]: +def _write_rietveld_blocks(project: object, used_block_names: set[str]) -> list[str]: """Render powder Rietveld overall, phase, and pattern blocks.""" experiments = _powder_rietveld_experiments(project) if not experiments: return [] - phases = _powder_phases(project, experiments) - patterns = _powder_patterns(project, experiments) + overall_block_name = _unique_block_name('overall', used_block_names) + phases = _powder_phases(project, experiments, used_block_names) + patterns = _powder_patterns(experiments, used_block_names) return [ - _write_rietveld_overall_block(project, phases, patterns), + _write_rietveld_overall_block(project, phases, patterns, overall_block_name), *[_write_powder_phase_block(phase) for phase in phases], *[_write_powder_pattern_block(project, pattern, phases) for pattern in patterns], ] @@ -493,22 +411,19 @@ def _write_rietveld_overall_block( project: object, phases: list[_PowderPhase], patterns: list[_PowderPattern], + block_name: str, ) -> str: """Render the powder Rietveld overall block.""" - lines = [f'data_{_block_name(project.name)}_overall'] + lines = [f'data_{block_name}'] fit_result = _fit_result(project) _section(lines, 'Rietveld overall') _write_item(lines, '_pd_calc.method', 'Rietveld Refinement') - _write_item( - lines, - '_pd_block_id', - _pipe_ids([phase.block_name for phase in phases]), - ) - _write_item( + _write_reference_values(lines, '_pd_block_id', [phase.block_name for phase in phases]) + _write_reference_values( lines, '_pd_block_diffractogram_id', - _pipe_ids([pattern.block_name for pattern in patterns]), + [pattern.block_name for pattern in patterns], ) _section(lines, 'Powder refinement') @@ -587,7 +502,7 @@ def _write_powder_pattern_reference_section( phases, ) _section(lines, 'Powder pattern') - _write_item(lines, '_pd_block_id', _pipe_ids(phase_ids)) + _write_reference_values(lines, '_pd_block_id', phase_ids) _write_item(lines, '_pd_block_diffractogram_id', pattern.block_name) @@ -601,9 +516,6 @@ def _write_powder_measurement_section( _section(lines, 'Powder measurement') _write_item(lines, '_pd_meas.scan_method', _attribute_value(expt_type, 'beam_mode')) _write_item(lines, '_pd_meas.number_of_points', len(data_items)) - _write_item(lines, '_pd_meas.info_author_name', '?') - _write_item(lines, '_pd_meas.info_author_email', '?') - _write_item(lines, '_pd_meas.info_author_phone', '?') def _write_powder_proc_section(lines: list[str], experiment: object) -> None: @@ -704,65 +616,6 @@ def _write_tof_calibration_loop(lines: list[str], experiment: object) -> None: _write_loop(lines, loop.tags, loop.rows) -def _write_publication_item_section( - lines: list[str], - title: str, - category: object, - items: Iterable[tuple[str, str]], -) -> None: - """Append one scalar publication metadata section.""" - _section(lines, title) - for tag, attr_name in items: - _write_item(lines, tag, _attribute_value(category, attr_name)) - - -def _write_publication_body_section(lines: list[str], body: object) -> None: - """Append publication body metadata.""" - _section(lines, 'Publication body') - for tag, attr_name in _PUBL_BODY_ITEMS: - _write_item(lines, tag, _publication_body_value(body, attr_name)) - - -def _publication_body_value(body: object, attr_name: str) -> object: - """Return one publication-body value.""" - if attr_name != 'contents': - return _attribute_value(body, attr_name) - - return _publication_body_contents(body) - - -def _publication_body_contents(body: object) -> str | None: - """Return IUCr publication body contents from discrete fields.""" - if body is None: - return None - - sections = [] - for attr_name in ('synopsis', 'abstract'): - value = _attribute_value(body, attr_name) - if value not in {None, ''}: - sections.append(str(value)) - - keywords = getattr(body, 'keywords', []) - if keywords: - sections.append(f'Keywords: {", ".join(keywords)}') - - if not sections: - return None - return '\n\n'.join(sections) - - -def _publication_author_rows(publication: object) -> list[tuple[object, ...]]: - """Return publication author rows or one empty placeholder row.""" - authors = getattr(publication, 'authors', None) - rows = [ - tuple(_attribute_value(author, attr_name) for _, attr_name in _PUBL_AUTHOR_ITEMS) - for author in _collection_values(authors) - ] - if rows: - return rows - return [tuple(None for _ in _PUBL_AUTHOR_ITEMS)] - - def _write_loop( lines: list[str], tags: Iterable[str], @@ -1178,6 +1031,7 @@ def _powder_rietveld_experiments(project: object) -> list[object]: def _powder_phases( project: object, experiments: list[object], + used_block_names: set[str], ) -> list[_PowderPhase]: """Return unique powder phase blocks for the given experiments.""" phases: list[_PowderPhase] = [] @@ -1188,10 +1042,9 @@ def _powder_phases( if structure_name in seen_structure_names: continue seen_structure_names.add(structure_name) - block_name = f'{_block_name(project.name)}_phase_{len(phases) + 1}' phases.append( _PowderPhase( - block_name=block_name, + block_name=_unique_block_name(structure_name, used_block_names), structure=structure, linked_phase=linked_phase, ) @@ -1200,13 +1053,16 @@ def _powder_phases( def _powder_patterns( - project: object, experiments: list[object], + used_block_names: set[str], ) -> list[_PowderPattern]: """Return powder pattern blocks for the given experiments.""" return [ _PowderPattern( - block_name=f'{_block_name(project.name)}_pwd_{index}', + block_name=_unique_block_name( + getattr(experiment, 'name', None) or f'pattern_{index}', + used_block_names, + ), experiment=experiment, ) for index, experiment in enumerate(experiments, start=1) @@ -1244,11 +1100,27 @@ def _linked_powder_structures( raise ValueError(msg) -def _pipe_ids(values: list[str]) -> str: - """Return pipe-delimited block identifiers.""" +def _write_reference_values(lines: list[str], tag: str, values: list[str]) -> None: + """Append scalar or loop block references.""" if not values: - return '?' - return '|' + '|'.join(values) + '|' + _write_item(lines, tag, '?') + return + if len(values) == 1: + _write_item(lines, tag, values[0]) + return + _write_loop(lines, (tag,), [(value,) for value in values]) + + +def _unique_block_name(value: object, used_block_names: set[str]) -> str: + """Return a CIF block name unique in the report.""" + block_name = _block_name(value) + candidate = block_name + suffix = 2 + while candidate in used_block_names: + candidate = f'{block_name}_{suffix}' + suffix += 1 + used_block_names.add(candidate) + return candidate def _phase_block_names_for_experiment( diff --git a/src/easydiffraction/io/cif/serialize.py b/src/easydiffraction/io/cif/serialize.py index e3080aa97..4adea6aa4 100644 --- a/src/easydiffraction/io/cif/serialize.py +++ b/src/easydiffraction/io/cif/serialize.py @@ -566,7 +566,7 @@ def _as_cif_text(section: object) -> str: def project_config_to_cif(project: object) -> str: """Render project-level configuration to ``project.cif`` text.""" sections: list[str] = [] - for attr_name in ('info', 'chart', 'report'): + for attr_name in ('info', 'rendering_plot', 'report'): section = getattr(project, attr_name, None) if section is not None: sections.append(_as_cif_text(section)) @@ -575,7 +575,13 @@ def project_config_to_cif(project: object) -> str: if publication is not None: sections.append(category_owner_to_cif(publication)) - for attr_name in ('table', 'verbosity'): + for attr_name in ( + 'rendering_table', + 'rendering_structure', + 'structure_view', + 'structure_style', + 'verbosity', + ): section = getattr(project, attr_name, None) if section is not None: sections.append(_as_cif_text(section)) @@ -684,9 +690,9 @@ def project_config_from_cif(project: object, cif_text: str) -> None: _populate_project_info_from_block(project.info, block) - chart = getattr(project, 'chart', None) - if chart is not None: - chart.from_cif(block) + rendering_plot = getattr(project, 'rendering_plot', None) + if rendering_plot is not None: + rendering_plot.from_cif(block) report = getattr(project, 'report', None) if report is not None: @@ -697,14 +703,26 @@ def project_config_from_cif(project: object, cif_text: str) -> None: if publication is not None: publication.from_cif(block) - table = getattr(project, 'table', None) - if table is not None: - table.from_cif(block) + rendering_table = getattr(project, 'rendering_table', None) + if rendering_table is not None: + rendering_table.from_cif(block) verbosity = getattr(project, 'verbosity', None) if verbosity is not None: verbosity.from_cif(block) + rendering_structure = getattr(project, 'rendering_structure', None) + if rendering_structure is not None: + rendering_structure.from_cif(block) + + structure_view = getattr(project, 'structure_view', None) + if structure_view is not None: + structure_view.from_cif(block) + + structure_style = getattr(project, 'structure_style', None) + if structure_style is not None: + structure_style.from_cif(block) + def analysis_from_cif(analysis: object, cif_text: str) -> None: """ diff --git a/src/easydiffraction/project/categories/chart/__init__.py b/src/easydiffraction/project/categories/chart/__init__.py deleted file mode 100644 index 1ae8bf874..000000000 --- a/src/easydiffraction/project/categories/chart/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project chart category exports.""" - -from __future__ import annotations - -from easydiffraction.project.categories.chart.default import Chart -from easydiffraction.project.categories.chart.factory import ChartFactory diff --git a/src/easydiffraction/project/categories/publication/__init__.py b/src/easydiffraction/project/categories/publication/__init__.py deleted file mode 100644 index f1aa7a046..000000000 --- a/src/easydiffraction/project/categories/publication/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project publication metadata exports.""" - -from __future__ import annotations - -from easydiffraction.project.categories.publication.default import Publication -from easydiffraction.project.categories.publication.default import PublicationAuthor -from easydiffraction.project.categories.publication.default import PublicationAuthors -from easydiffraction.project.categories.publication.default import PublicationBody -from easydiffraction.project.categories.publication.default import PublicationContactAuthor -from easydiffraction.project.categories.publication.default import PublicationJournal -from easydiffraction.project.categories.publication.default import PublicationJournalCoeditor -from easydiffraction.project.categories.publication.default import PublicationJournalDate -from easydiffraction.project.categories.publication.factory import PublicationFactory diff --git a/src/easydiffraction/project/categories/publication/default.py b/src/easydiffraction/project/categories/publication/default.py deleted file mode 100644 index e312e2a7d..000000000 --- a/src/easydiffraction/project/categories/publication/default.py +++ /dev/null @@ -1,657 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project publication metadata categories.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from easydiffraction.core.category import CategoryCollection -from easydiffraction.core.category import CategoryItem -from easydiffraction.core.category_owner import CategoryOwner -from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.variable import StringDescriptor -from easydiffraction.io.cif.handler import CifHandler -from easydiffraction.project.categories.publication.factory import PublicationFactory -from easydiffraction.project.publication_loader import load_publication - -if TYPE_CHECKING: - import pathlib - - -class PublicationItemBase(CategoryItem): - """Base for optional publication metadata scalar categories.""" - - @staticmethod - def _optional_string( - *, - name: str, - cif_name: str, - description: str, - ) -> StringDescriptor: - """Create a nullable publication string descriptor.""" - return StringDescriptor( - name=name, - description=description, - value_spec=AttributeSpec(default=None, allow_none=True), - cif_handler=CifHandler(names=[cif_name]), - ) - - -class PublicationJournal(PublicationItemBase): - """Journal metadata for publication reports.""" - - _category_code = 'journal' - - def __init__(self) -> None: - super().__init__() - self._name_full = self._optional_string( - name='name_full', - cif_name='_journal.name_full', - description='Full journal name.', - ) - self._year = self._optional_string( - name='year', - cif_name='_journal.year', - description='Journal publication year.', - ) - self._volume = self._optional_string( - name='volume', - cif_name='_journal.volume', - description='Journal volume.', - ) - self._issue = self._optional_string( - name='issue', - cif_name='_journal.issue', - description='Journal issue.', - ) - self._page_first = self._optional_string( - name='page_first', - cif_name='_journal.page_first', - description='First journal page.', - ) - self._page_last = self._optional_string( - name='page_last', - cif_name='_journal.page_last', - description='Last journal page.', - ) - self._paper_category = self._optional_string( - name='paper_category', - cif_name='_journal.paper_category', - description='Journal paper category.', - ) - self._paper_doi = self._optional_string( - name='paper_doi', - cif_name='_journal.paper_DOI', - description='Journal paper DOI.', - ) - self._coden_astm = self._optional_string( - name='coden_astm', - cif_name='_journal.coden_ASTM', - description='Journal CODEN ASTM identifier.', - ) - self._suppl_publ_number = self._optional_string( - name='suppl_publ_number', - cif_name='_journal.suppl_publ_number', - description='Supplementary publication number.', - ) - - @property - def name_full(self) -> StringDescriptor: - """Full journal name.""" - return self._name_full - - @name_full.setter - def name_full(self, value: str | None) -> None: - self._name_full.value = value - - @property - def year(self) -> StringDescriptor: - """Journal publication year.""" - return self._year - - @year.setter - def year(self, value: str | None) -> None: - self._year.value = value - - @property - def volume(self) -> StringDescriptor: - """Journal volume.""" - return self._volume - - @volume.setter - def volume(self, value: str | None) -> None: - self._volume.value = value - - @property - def issue(self) -> StringDescriptor: - """Journal issue.""" - return self._issue - - @issue.setter - def issue(self, value: str | None) -> None: - self._issue.value = value - - @property - def page_first(self) -> StringDescriptor: - """First journal page.""" - return self._page_first - - @page_first.setter - def page_first(self, value: str | None) -> None: - self._page_first.value = value - - @property - def page_last(self) -> StringDescriptor: - """Last journal page.""" - return self._page_last - - @page_last.setter - def page_last(self, value: str | None) -> None: - self._page_last.value = value - - @property - def paper_category(self) -> StringDescriptor: - """Journal paper category.""" - return self._paper_category - - @paper_category.setter - def paper_category(self, value: str | None) -> None: - self._paper_category.value = value - - @property - def paper_doi(self) -> StringDescriptor: - """Journal paper DOI.""" - return self._paper_doi - - @paper_doi.setter - def paper_doi(self, value: str | None) -> None: - self._paper_doi.value = value - - @property - def coden_astm(self) -> StringDescriptor: - """Journal CODEN ASTM identifier.""" - return self._coden_astm - - @coden_astm.setter - def coden_astm(self, value: str | None) -> None: - self._coden_astm.value = value - - @property - def suppl_publ_number(self) -> StringDescriptor: - """Supplementary publication number.""" - return self._suppl_publ_number - - @suppl_publ_number.setter - def suppl_publ_number(self, value: str | None) -> None: - self._suppl_publ_number.value = value - - -class PublicationJournalDate(PublicationItemBase): - """Journal editorial date metadata.""" - - _category_code = 'journal_date' - - def __init__(self) -> None: - super().__init__() - self._accepted = self._optional_string( - name='accepted', - cif_name='_journal_date.accepted', - description='Accepted date.', - ) - self._from_coeditor = self._optional_string( - name='from_coeditor', - cif_name='_journal_date.from_coeditor', - description='Date sent from coeditor.', - ) - self._printers_final = self._optional_string( - name='printers_final', - cif_name='_journal_date.printers_final', - description='Final printer date.', - ) - - @property - def accepted(self) -> StringDescriptor: - """Accepted date.""" - return self._accepted - - @accepted.setter - def accepted(self, value: str | None) -> None: - self._accepted.value = value - - @property - def from_coeditor(self) -> StringDescriptor: - """Date sent from coeditor.""" - return self._from_coeditor - - @from_coeditor.setter - def from_coeditor(self, value: str | None) -> None: - self._from_coeditor.value = value - - @property - def printers_final(self) -> StringDescriptor: - """Final printer date.""" - return self._printers_final - - @printers_final.setter - def printers_final(self, value: str | None) -> None: - self._printers_final.value = value - - -class PublicationJournalCoeditor(PublicationItemBase): - """Journal coeditor metadata.""" - - _category_code = 'journal_coeditor' - - def __init__(self) -> None: - super().__init__() - self._code = self._optional_string( - name='code', - cif_name='_journal_coeditor.code', - description='Journal coeditor code.', - ) - self._name = self._optional_string( - name='name', - cif_name='_journal_coeditor.name', - description='Journal coeditor name.', - ) - self._notes = self._optional_string( - name='notes', - cif_name='_journal_coeditor.notes', - description='Journal coeditor notes.', - ) - - @property - def code(self) -> StringDescriptor: - """Journal coeditor code.""" - return self._code - - @code.setter - def code(self, value: str | None) -> None: - self._code.value = value - - @property - def name(self) -> StringDescriptor: - """Journal coeditor name.""" - return self._name - - @name.setter - def name(self, value: str | None) -> None: - self._name.value = value - - @property - def notes(self) -> StringDescriptor: - """Journal coeditor notes.""" - return self._notes - - @notes.setter - def notes(self, value: str | None) -> None: - self._notes.value = value - - -class PublicationContactAuthor(PublicationItemBase): - """Publication contact-author metadata.""" - - _category_code = 'publ_contact_author' - - def __init__(self) -> None: - super().__init__() - self._name = self._optional_string( - name='name', - cif_name='_publ_contact_author.name', - description='Contact author name.', - ) - self._address = self._optional_string( - name='address', - cif_name='_publ_contact_author.address', - description='Contact author address.', - ) - self._email = self._optional_string( - name='email', - cif_name='_publ_contact_author.email', - description='Contact author email.', - ) - self._phone = self._optional_string( - name='phone', - cif_name='_publ_contact_author.phone', - description='Contact author phone.', - ) - self._id_orcid = self._optional_string( - name='id_orcid', - cif_name='_publ_contact_author.id_ORCID', - description='Contact author ORCID identifier.', - ) - self._id_iucr = self._optional_string( - name='id_iucr', - cif_name='_publ_contact_author.id_IUCr', - description='Contact author IUCr identifier.', - ) - - @property - def name(self) -> StringDescriptor: - """Contact author name.""" - return self._name - - @name.setter - def name(self, value: str | None) -> None: - self._name.value = value - - @property - def address(self) -> StringDescriptor: - """Contact author address.""" - return self._address - - @address.setter - def address(self, value: str | None) -> None: - self._address.value = value - - @property - def email(self) -> StringDescriptor: - """Contact author email.""" - return self._email - - @email.setter - def email(self, value: str | None) -> None: - self._email.value = value - - @property - def phone(self) -> StringDescriptor: - """Contact author phone.""" - return self._phone - - @phone.setter - def phone(self, value: str | None) -> None: - self._phone.value = value - - @property - def id_orcid(self) -> StringDescriptor: - """Contact author ORCID identifier.""" - return self._id_orcid - - @id_orcid.setter - def id_orcid(self, value: str | None) -> None: - self._id_orcid.value = value - - @property - def id_iucr(self) -> StringDescriptor: - """Contact author IUCr identifier.""" - return self._id_iucr - - @id_iucr.setter - def id_iucr(self, value: str | None) -> None: - self._id_iucr.value = value - - -class PublicationBody(PublicationItemBase): - """Publication body metadata.""" - - _category_code = 'publ_body' - - def __init__(self) -> None: - super().__init__() - self._title = self._optional_string( - name='title', - cif_name='_publ_body.title', - description='Publication title.', - ) - self._synopsis = self._optional_string( - name='synopsis', - cif_name='_publ_body.synopsis', - description='Publication synopsis.', - ) - self._abstract = self._optional_string( - name='abstract', - cif_name='_publ_body.abstract', - description='Publication abstract.', - ) - self._keywords = self._optional_string( - name='keywords', - cif_name='_publ_body.keywords', - description='Publication keywords.', - ) - - @property - def title(self) -> StringDescriptor: - """Publication title.""" - return self._title - - @title.setter - def title(self, value: str | None) -> None: - self._title.value = value - - @property - def synopsis(self) -> StringDescriptor: - """Publication synopsis.""" - return self._synopsis - - @synopsis.setter - def synopsis(self, value: str | None) -> None: - self._synopsis.value = value - - @property - def abstract(self) -> StringDescriptor: - """Publication abstract.""" - return self._abstract - - @abstract.setter - def abstract(self, value: str | None) -> None: - self._abstract.value = value - - @property - def keywords(self) -> list[str]: - """Publication keywords.""" - value = self._keywords.value - if value in {None, ''}: - return [] - return str(value).splitlines() - - @keywords.setter - def keywords(self, value: list[str]) -> None: - self._keywords.value = '\n'.join(value) - - -class PublicationAuthor(PublicationItemBase): - """Single publication author row.""" - - _category_code = 'publ_author' - _category_entry_name = 'name' - - def __init__(self) -> None: - super().__init__() - self._name = self._optional_string( - name='name', - cif_name='_publ_author.name', - description='Publication author name.', - ) - self._address = self._optional_string( - name='address', - cif_name='_publ_author.address', - description='Publication author address.', - ) - self._footnote = self._optional_string( - name='footnote', - cif_name='_publ_author.footnote', - description='Publication author footnote.', - ) - self._id_orcid = self._optional_string( - name='id_orcid', - cif_name='_publ_author.id_ORCID', - description='Publication author ORCID identifier.', - ) - self._id_iucr = self._optional_string( - name='id_iucr', - cif_name='_publ_author.id_IUCr', - description='Publication author IUCr identifier.', - ) - - @property - def name(self) -> StringDescriptor: - """Publication author name.""" - return self._name - - @name.setter - def name(self, value: str | None) -> None: - self._name.value = value - - @property - def address(self) -> StringDescriptor: - """Publication author address.""" - return self._address - - @address.setter - def address(self, value: str | None) -> None: - self._address.value = value - - @property - def footnote(self) -> StringDescriptor: - """Publication author footnote.""" - return self._footnote - - @footnote.setter - def footnote(self, value: str | None) -> None: - self._footnote.value = value - - @property - def id_orcid(self) -> StringDescriptor: - """Publication author ORCID identifier.""" - return self._id_orcid - - @id_orcid.setter - def id_orcid(self, value: str | None) -> None: - self._id_orcid.value = value - - @property - def id_iucr(self) -> StringDescriptor: - """Publication author IUCr identifier.""" - return self._id_iucr - - @id_iucr.setter - def id_iucr(self, value: str | None) -> None: - self._id_iucr.value = value - - -class PublicationAuthors(CategoryCollection): - """Publication author rows.""" - - def __init__(self) -> None: - """Create an empty publication-author collection.""" - super().__init__(item_type=PublicationAuthor) - - def add( - self, - *, - name: str, - address: str | None = None, - footnote: str | None = None, - id_orcid: str | None = None, - id_iucr: str | None = None, - ) -> PublicationAuthor: - """ - Add or replace one publication author row. - - Parameters - ---------- - name : str - Author name. - address : str | None, default=None - Author address. - footnote : str | None, default=None - Author footnote. - id_orcid : str | None, default=None - Author ORCID identifier. - id_iucr : str | None, default=None - Author IUCr identifier. - - Returns - ------- - PublicationAuthor - The inserted author row. - """ - author = PublicationAuthor() - author.name = name - author.address = address - author.footnote = footnote - author.id_orcid = id_orcid - author.id_iucr = id_iucr - super().add(author) - return author - - -@PublicationFactory.register -class Publication(CategoryOwner): - """Project publication metadata facade.""" - - type_info = TypeInfo( - tag='default', - description='Project publication metadata', - ) - - def __init__(self) -> None: - super().__init__() - self._journal = PublicationJournal() - self._journal_date = PublicationJournalDate() - self._journal_coeditor = PublicationJournalCoeditor() - self._contact_author = PublicationContactAuthor() - self._body = PublicationBody() - self._authors = PublicationAuthors() - - @property - def journal(self) -> PublicationJournal: - """Journal metadata.""" - return self._journal - - @property - def journal_date(self) -> PublicationJournalDate: - """Journal editorial date metadata.""" - return self._journal_date - - @property - def journal_coeditor(self) -> PublicationJournalCoeditor: - """Journal coeditor metadata.""" - return self._journal_coeditor - - @property - def contact_author(self) -> PublicationContactAuthor: - """Publication contact-author metadata.""" - return self._contact_author - - @property - def body(self) -> PublicationBody: - """Publication body metadata.""" - return self._body - - @property - def authors(self) -> PublicationAuthors: - """Publication author rows.""" - return self._authors - - @property - def as_cif(self) -> str: - """Serialize publication metadata to CIF.""" - from easydiffraction.io.cif.serialize import category_owner_to_cif # noqa: PLC0415 - - return category_owner_to_cif(self) - - def from_cif(self, block: object) -> None: - """ - Populate publication metadata from a project CIF block. - - Parameters - ---------- - block : object - Parsed CIF block containing project-level publication tags. - """ - for category in self.categories: - category.from_cif(block) - - def load(self, path: str | pathlib.Path) -> None: - """ - Load publication metadata from a TOML or JSON file. - - Parameters - ---------- - path : str | pathlib.Path - File path ending in ``.toml`` or ``.json``. - """ - load_publication(self, path) diff --git a/src/easydiffraction/project/categories/rendering_plot/__init__.py b/src/easydiffraction/project/categories/rendering_plot/__init__.py new file mode 100644 index 000000000..140a114ec --- /dev/null +++ b/src/easydiffraction/project/categories/rendering_plot/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project rendering_plot category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.rendering_plot.default import RenderingPlot +from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory diff --git a/src/easydiffraction/project/categories/chart/default.py b/src/easydiffraction/project/categories/rendering_plot/default.py similarity index 70% rename from src/easydiffraction/project/categories/chart/default.py rename to src/easydiffraction/project/categories/rendering_plot/default.py index 5d45cdcea..bf058a661 100644 --- a/src/easydiffraction/project/categories/chart/default.py +++ b/src/easydiffraction/project/categories/rendering_plot/default.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Project chart category.""" +"""Project rendering_plot category.""" from __future__ import annotations @@ -15,25 +15,25 @@ from easydiffraction.display.plotting import PlotterFactory from easydiffraction.io.cif.handler import CifHandler from easydiffraction.io.cif.parse import read_cif_str -from easydiffraction.project.categories.chart.factory import ChartFactory +from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory from easydiffraction.utils.logging import log AUTO_ENGINE = 'auto' -AUTO_DESCRIPTION = 'Environment default chart engine' +AUTO_DESCRIPTION = 'Environment default rendering_plot engine' CHART_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in PlotterEngineEnum]] -@ChartFactory.register -class Chart(CategoryItem, SwitchableCategoryBase): - """Chart engine selection for a project.""" +@RenderingPlotFactory.register +class RenderingPlot(CategoryItem, SwitchableCategoryBase): + """RenderingPlot engine selection for a project.""" - _category_code = 'chart' - _owner_attr_name = 'chart' - _swap_method_name = '_swap_chart' + _category_code = 'rendering_plot' + _owner_attr_name = 'rendering_plot' + _swap_method_name = '_swap_rendering_plot' type_info = TypeInfo( tag='default', - description='Project chart category', + description='Project rendering_plot category', ) def __init__(self) -> None: @@ -42,14 +42,14 @@ def __init__(self) -> None: self._plotter = Plotter() self._type = StringDescriptor( name='type', - description='Chart renderer backend type', + description='RenderingPlot renderer backend type', value_spec=AttributeSpec( default=AUTO_ENGINE, validator=MembershipValidator( allowed=CHART_ENGINE_OPTIONS, ), ), - cif_handler=CifHandler(names=['_chart.type']), + cif_handler=CifHandler(names=['_rendering_plot.type']), ) @staticmethod @@ -61,9 +61,9 @@ def _resolved_engine(value: str) -> str: def _set_type(self, value: str, *, strict: bool = True) -> None: if value not in CHART_ENGINE_OPTIONS: msg = ( - f"Unsupported chart type '{value}'. " + f"Unsupported rendering_plot type '{value}'. " f'Supported: {CHART_ENGINE_OPTIONS}. ' - f"For more information, use 'chart.show_supported()'" + f"For more information, use 'rendering_plot.show_supported()'" ) if strict: raise ValueError(msg) @@ -87,14 +87,14 @@ def plotter(self) -> Plotter: def _supported_types( filters: dict[str, object], ) -> list[tuple[str, str]]: - """Return supported chart renderer backends.""" + """Return supported rendering_plot renderer backends.""" del filters return [(AUTO_ENGINE, AUTO_DESCRIPTION), *PlotterFactory.descriptions()] def from_cif(self, block: object, idx: int = 0) -> None: - """Populate this chart category from a CIF block.""" + """Populate this rendering_plot category from a CIF block.""" del idx - chart_type = read_cif_str(block, '_chart.type') - if chart_type is None: + rendering_plot_type = read_cif_str(block, '_rendering_plot.type') + if rendering_plot_type is None: return - self._parent._swap_chart(chart_type, strict=False) + self._parent._swap_rendering_plot(rendering_plot_type, strict=False) diff --git a/src/easydiffraction/project/categories/publication/factory.py b/src/easydiffraction/project/categories/rendering_plot/factory.py similarity index 68% rename from src/easydiffraction/project/categories/publication/factory.py rename to src/easydiffraction/project/categories/rendering_plot/factory.py index 0f2926339..2d7c2ab65 100644 --- a/src/easydiffraction/project/categories/publication/factory.py +++ b/src/easydiffraction/project/categories/rendering_plot/factory.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Factory for project publication metadata owners.""" +"""Factory for project rendering_plot categories.""" from __future__ import annotations @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class PublicationFactory(FactoryBase): - """Create project publication metadata owners.""" +class RenderingPlotFactory(FactoryBase): + """Create project rendering_plot category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/project/categories/rendering_structure/__init__.py b/src/easydiffraction/project/categories/rendering_structure/__init__.py new file mode 100644 index 000000000..228378d45 --- /dev/null +++ b/src/easydiffraction/project/categories/rendering_structure/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project rendering_structure category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.rendering_structure.default import RenderingStructure +from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, +) diff --git a/src/easydiffraction/project/categories/rendering_structure/default.py b/src/easydiffraction/project/categories/rendering_structure/default.py new file mode 100644 index 000000000..5a59ec7d4 --- /dev/null +++ b/src/easydiffraction/project/categories/rendering_structure/default.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project rendering_structure category (switchable renderer engine).""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.switchable import SwitchableCategoryBase +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import MembershipValidator +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.display.structure.enums import ViewerEngineEnum +from easydiffraction.display.structure.viewing import Viewer +from easydiffraction.display.structure.viewing import ViewerFactory +from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.io.cif.parse import read_cif_str +from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, +) +from easydiffraction.utils.logging import log + +AUTO_ENGINE = 'auto' +AUTO_DESCRIPTION = 'Environment default structure-view engine' +VIEW_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in ViewerEngineEnum]] + + +@RenderingStructureFactory.register +class RenderingStructure(CategoryItem, SwitchableCategoryBase): + """Renderer engine selection for the project structure view.""" + + _category_code = 'rendering_structure' + _owner_attr_name = 'rendering_structure' + _swap_method_name = '_swap_rendering_structure' + + type_info = TypeInfo( + tag='default', + description='Project rendering_structure category', + ) + + def __init__(self) -> None: + super().__init__() + + self._viewer = Viewer() + self._type = StringDescriptor( + name='type', + description='Structure-view renderer backend type', + value_spec=AttributeSpec( + default=AUTO_ENGINE, + validator=MembershipValidator(allowed=VIEW_ENGINE_OPTIONS), + ), + cif_handler=CifHandler(names=['_rendering_structure.type']), + ) + + @staticmethod + def _resolved_engine(value: str) -> str: + if value == AUTO_ENGINE: + return ViewerEngineEnum.default().value + return value + + def _set_type(self, value: str, *, strict: bool = True) -> None: + if value not in VIEW_ENGINE_OPTIONS: + msg = ( + f"Unsupported rendering_structure type '{value}'. " + f'Supported: {VIEW_ENGINE_OPTIONS}. ' + f"For more information, use 'rendering_structure.show_supported()'" + ) + if strict: + raise ValueError(msg) + log.warning(msg) + return + resolved_engine = self._resolved_engine(value) + if self._viewer.engine != resolved_engine: + self._viewer.engine = resolved_engine + self._type.value = value + + @staticmethod + def _supported_types(filters: dict[str, object]) -> list[tuple[str, str]]: + """Return supported structure-view renderer backends.""" + del filters + return [(AUTO_ENGINE, AUTO_DESCRIPTION), *ViewerFactory.descriptions()] + + @property + def viewer(self) -> Viewer: + """Live structure-view facade bound to the active engine.""" + return self._viewer + + def from_cif(self, block: object, idx: int = 0) -> None: + """Populate this category from a CIF block, rebinding engine.""" + super().from_cif(block, idx) + view_type = read_cif_str(block, '_rendering_structure.type') + if view_type is not None: + self._parent._swap_rendering_structure(view_type, strict=False) + + @property + def as_cif(self) -> str: + """Return the CIF text for this rendering_structure category.""" + return super().as_cif diff --git a/src/easydiffraction/project/categories/rendering_structure/factory.py b/src/easydiffraction/project/categories/rendering_structure/factory.py new file mode 100644 index 000000000..cdcc6d251 --- /dev/null +++ b/src/easydiffraction/project/categories/rendering_structure/factory.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Factory for project view categories.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.core.factory import FactoryBase + + +class RenderingStructureFactory(FactoryBase): + """Create project rendering_structure category instances.""" + + _default_rules: ClassVar[dict] = { + frozenset(): 'default', + } diff --git a/src/easydiffraction/project/categories/rendering_table/__init__.py b/src/easydiffraction/project/categories/rendering_table/__init__.py new file mode 100644 index 000000000..59a65396a --- /dev/null +++ b/src/easydiffraction/project/categories/rendering_table/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project rendering_table category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.rendering_table.default import RenderingTable +from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory diff --git a/src/easydiffraction/project/categories/table/default.py b/src/easydiffraction/project/categories/rendering_table/default.py similarity index 76% rename from src/easydiffraction/project/categories/table/default.py rename to src/easydiffraction/project/categories/rendering_table/default.py index 00aa5f47a..98ef5ca47 100644 --- a/src/easydiffraction/project/categories/table/default.py +++ b/src/easydiffraction/project/categories/rendering_table/default.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Project table category.""" +"""Project rendering_table category.""" from __future__ import annotations @@ -15,7 +15,7 @@ from easydiffraction.display.tables import TableRendererFactory from easydiffraction.io.cif.handler import CifHandler from easydiffraction.io.cif.parse import read_cif_str -from easydiffraction.project.categories.table.factory import TableFactory +from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory from easydiffraction.utils.logging import log AUTO_ENGINE = 'auto' @@ -23,17 +23,17 @@ TABLE_ENGINE_OPTIONS = [AUTO_ENGINE, *[member.value for member in TableEngineEnum]] -@TableFactory.register -class Table(CategoryItem, SwitchableCategoryBase): +@RenderingTableFactory.register +class RenderingTable(CategoryItem, SwitchableCategoryBase): """Table engine selection for a project.""" - _category_code = 'table' - _owner_attr_name = 'table' - _swap_method_name = '_swap_table' + _category_code = 'rendering_table' + _owner_attr_name = 'rendering_table' + _swap_method_name = '_swap_rendering_table' type_info = TypeInfo( tag='default', - description='Project table category', + description='Project rendering_table category', ) def __init__(self) -> None: @@ -49,7 +49,7 @@ def __init__(self) -> None: allowed=TABLE_ENGINE_OPTIONS, ), ), - cif_handler=CifHandler(names=['_table.type']), + cif_handler=CifHandler(names=['_rendering_table.type']), ) @staticmethod @@ -61,9 +61,9 @@ def _resolved_engine(value: str) -> str: def _set_type(self, value: str, *, strict: bool = True) -> None: if value not in TABLE_ENGINE_OPTIONS: msg = ( - f"Unsupported table type '{value}'. " + f"Unsupported rendering_table type '{value}'. " f'Supported: {TABLE_ENGINE_OPTIONS}. ' - f"For more information, use 'table.show_supported()'" + f"For more information, use 'rendering_table.show_supported()'" ) if strict: raise ValueError(msg) @@ -89,9 +89,9 @@ def _supported_types( return [(AUTO_ENGINE, AUTO_DESCRIPTION), *TableRendererFactory.descriptions()] def from_cif(self, block: object, idx: int = 0) -> None: - """Populate this table category from a CIF block.""" + """Populate this rendering_table category from a CIF block.""" del idx - table_type = read_cif_str(block, '_table.type') + table_type = read_cif_str(block, '_rendering_table.type') if table_type is None: return - self._parent._swap_table(table_type, strict=False) + self._parent._swap_rendering_table(table_type, strict=False) diff --git a/src/easydiffraction/project/categories/table/factory.py b/src/easydiffraction/project/categories/rendering_table/factory.py similarity index 78% rename from src/easydiffraction/project/categories/table/factory.py rename to src/easydiffraction/project/categories/rendering_table/factory.py index eac859310..c7b7f0d82 100644 --- a/src/easydiffraction/project/categories/table/factory.py +++ b/src/easydiffraction/project/categories/rendering_table/factory.py @@ -9,8 +9,8 @@ from easydiffraction.core.factory import FactoryBase -class TableFactory(FactoryBase): - """Create project table category instances.""" +class RenderingTableFactory(FactoryBase): + """Create project rendering_table category instances.""" _default_rules: ClassVar[dict] = { frozenset(): 'default', diff --git a/src/easydiffraction/project/categories/report/default.py b/src/easydiffraction/project/categories/report/default.py index 054b0b398..156cd2f98 100644 --- a/src/easydiffraction/project/categories/report/default.py +++ b/src/easydiffraction/project/categories/report/default.py @@ -195,7 +195,7 @@ def as_html(self, *, offline: bool = False) -> str: """ from easydiffraction.report.html_renderer import render_html_report # noqa: PLC0415 - return render_html_report(self.data_context(), offline=offline) + return render_html_report(self.data_context(), offline=offline, project=self.project) def save_tex(self) -> pathlib.Path: """ diff --git a/src/easydiffraction/project/categories/structure_style/__init__.py b/src/easydiffraction/project/categories/structure_style/__init__.py new file mode 100644 index 000000000..bc92b70c7 --- /dev/null +++ b/src/easydiffraction/project/categories/structure_style/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project structure_style category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.structure_style.default import StructureStyle +from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory diff --git a/src/easydiffraction/project/categories/structure_style/default.py b/src/easydiffraction/project/categories/structure_style/default.py new file mode 100644 index 000000000..10ec8ac5f --- /dev/null +++ b/src/easydiffraction/project/categories/structure_style/default.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Project structure_style category (durable structure-view appearance). +""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.validation import RangeValidator +from easydiffraction.core.variable import EnumDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.display.structure.enums import AtomViewEnum +from easydiffraction.display.structure.enums import ColorSchemeEnum +from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + +@StructureStyleFactory.register +class StructureStyle(CategoryItem): + """How the structure view looks (appearance, not per-element).""" + + _category_code = 'structure_style' + + type_info = TypeInfo( + tag='default', + description='Project structure_style category', + ) + + def __init__(self) -> None: + super().__init__() + + self._atom_view = EnumDescriptor( + name='atom_view', + enum=AtomViewEnum, + description='How atoms are sized and shaped in the structure view.', + cif_handler=CifHandler(names=['_structure_style.atom_view']), + ) + self._color_scheme = EnumDescriptor( + name='color_scheme', + enum=ColorSchemeEnum, + description='Standard element colour scheme.', + cif_handler=CifHandler(names=['_structure_style.color_scheme']), + ) + self._adp_probability = NumericDescriptor( + name='adp_probability', + description='ORTEP probability level, a fraction in (0, 1).', + value_spec=AttributeSpec( + default=0.99, + validator=RangeValidator(gt=0.0, lt=1.0), + ), + cif_handler=CifHandler(names=['_structure_style.adp_probability']), + ) + self._atom_scale = NumericDescriptor( + name='atom_scale', + description='Overall ball-atom size factor (square-root compressed).', + value_spec=AttributeSpec( + default=0.3, + validator=RangeValidator(gt=0.0, le=1.0), + ), + cif_handler=CifHandler(names=['_structure_style.atom_scale']), + ) + + @property + def atom_view(self) -> EnumDescriptor: + """How atoms are sized/shaped (vdw/covalent/ionic/adp).""" + return self._atom_view + + @atom_view.setter + def atom_view(self, value: str) -> None: + self._atom_view.value = AtomViewEnum(value).value + + @property + def color_scheme(self) -> EnumDescriptor: + """Standard element colour scheme.""" + return self._color_scheme + + @color_scheme.setter + def color_scheme(self, value: str) -> None: + self._color_scheme.value = ColorSchemeEnum(value).value + + @property + def adp_probability(self) -> NumericDescriptor: + """ORTEP probability level (fraction in interval (0, 1)).""" + return self._adp_probability + + @adp_probability.setter + def adp_probability(self, value: float) -> None: + self._adp_probability.value = value + + @property + def atom_scale(self) -> NumericDescriptor: + """Overall ball-atom size factor in (0, 1] (sqrt compressed).""" + return self._atom_scale + + @atom_scale.setter + def atom_scale(self, value: float) -> None: + self._atom_scale.value = value + + @property + def as_cif(self) -> str: + """Return CIF text for this structure_style category.""" + return super().as_cif diff --git a/src/easydiffraction/project/categories/structure_style/factory.py b/src/easydiffraction/project/categories/structure_style/factory.py new file mode 100644 index 000000000..61df09a43 --- /dev/null +++ b/src/easydiffraction/project/categories/structure_style/factory.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Factory for project structure_style categories.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.core.factory import FactoryBase + + +class StructureStyleFactory(FactoryBase): + """Create project structure_style category instances.""" + + _default_rules: ClassVar[dict] = { + frozenset(): 'default', + } diff --git a/src/easydiffraction/project/categories/structure_view/__init__.py b/src/easydiffraction/project/categories/structure_view/__init__.py new file mode 100644 index 000000000..aafd3e971 --- /dev/null +++ b/src/easydiffraction/project/categories/structure_view/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Project structure_view category exports.""" + +from __future__ import annotations + +from easydiffraction.project.categories.structure_view.default import StructureView +from easydiffraction.project.categories.structure_view.factory import StructureViewFactory diff --git a/src/easydiffraction/project/categories/structure_view/default.py b/src/easydiffraction/project/categories/structure_view/default.py new file mode 100644 index 000000000..bf8ef46a4 --- /dev/null +++ b/src/easydiffraction/project/categories/structure_view/default.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Project structure_view category (durable content + region view state). +""" + +from __future__ import annotations + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.project.categories.structure_view.factory import StructureViewFactory +from easydiffraction.utils.logging import log + + +def _range_descriptor(name: str, default: float) -> NumericDescriptor: + return NumericDescriptor( + name=name, + description='Per-axis fractional view-range bound.', + value_spec=AttributeSpec(default=default), + cif_handler=CifHandler(names=[f'_structure_view.{name}']), + ) + + +@StructureViewFactory.register +class StructureView(CategoryItem): + """What and where to draw in the structure view (engine-neutral).""" + + _category_code = 'structure_view' + + type_info = TypeInfo( + tag='default', + description='Project structure_view category', + ) + + def __init__(self) -> None: + super().__init__() + + self._show_labels = BoolDescriptor( + name='show_labels', + description='Show atom labels when the view opens.', + value_spec=AttributeSpec(default=False), + cif_handler=CifHandler(names=['_structure_view.show_labels']), + ) + self._show_moments = BoolDescriptor( + name='show_moments', + description='Show magnetic-moment arrows where the data exists.', + value_spec=AttributeSpec(default=True), + cif_handler=CifHandler(names=['_structure_view.show_moments']), + ) + self._range_a_min = _range_descriptor('range_a_min', 0.0) + self._range_a_max = _range_descriptor('range_a_max', 1.0) + self._range_b_min = _range_descriptor('range_b_min', 0.0) + self._range_b_max = _range_descriptor('range_b_max', 1.0) + self._range_c_min = _range_descriptor('range_c_min', 0.0) + self._range_c_max = _range_descriptor('range_c_max', 1.0) + + @property + def show_labels(self) -> BoolDescriptor: + """Whether atom labels are shown when the view opens.""" + return self._show_labels + + @show_labels.setter + def show_labels(self, value: bool) -> None: + self._show_labels.value = value + + @property + def show_moments(self) -> BoolDescriptor: + """Whether moment arrows are shown where the data exists.""" + return self._show_moments + + @show_moments.setter + def show_moments(self, value: bool) -> None: + self._show_moments.value = value + + @staticmethod + def _set_bound( + descriptor: NumericDescriptor, + value: float, + *, + lower: float, + upper: float, + ) -> None: + if not lower < value < upper: + log.warning( + f"'{descriptor.name}' = {value} violates min < max on its axis; ignored.", + ) + return + descriptor.value = value + + @property + def range_a_min(self) -> NumericDescriptor: + """Lower fractional bound along a.""" + return self._range_a_min + + @range_a_min.setter + def range_a_min(self, value: float) -> None: + self._set_bound( + self._range_a_min, + value, + lower=float('-inf'), + upper=self._range_a_max.value, + ) + + @property + def range_a_max(self) -> NumericDescriptor: + """Upper fractional bound along a.""" + return self._range_a_max + + @range_a_max.setter + def range_a_max(self, value: float) -> None: + self._set_bound( + self._range_a_max, + value, + lower=self._range_a_min.value, + upper=float('inf'), + ) + + @property + def range_b_min(self) -> NumericDescriptor: + """Lower fractional bound along b.""" + return self._range_b_min + + @range_b_min.setter + def range_b_min(self, value: float) -> None: + self._set_bound( + self._range_b_min, + value, + lower=float('-inf'), + upper=self._range_b_max.value, + ) + + @property + def range_b_max(self) -> NumericDescriptor: + """Upper fractional bound along b.""" + return self._range_b_max + + @range_b_max.setter + def range_b_max(self, value: float) -> None: + self._set_bound( + self._range_b_max, + value, + lower=self._range_b_min.value, + upper=float('inf'), + ) + + @property + def range_c_min(self) -> NumericDescriptor: + """Lower fractional bound along c.""" + return self._range_c_min + + @range_c_min.setter + def range_c_min(self, value: float) -> None: + self._set_bound( + self._range_c_min, + value, + lower=float('-inf'), + upper=self._range_c_max.value, + ) + + @property + def range_c_max(self) -> NumericDescriptor: + """Upper fractional bound along c.""" + return self._range_c_max + + @range_c_max.setter + def range_c_max(self, value: float) -> None: + self._set_bound( + self._range_c_max, + value, + lower=self._range_c_min.value, + upper=float('inf'), + ) + + def view_range(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]: + """ + Assemble the per-axis ``((min, max), ...)`` fractional window. + """ + return ( + (self._range_a_min.value, self._range_a_max.value), + (self._range_b_min.value, self._range_b_max.value), + (self._range_c_min.value, self._range_c_max.value), + ) + + @property + def as_cif(self) -> str: + """Return CIF representation of this structure_view category.""" + return super().as_cif diff --git a/src/easydiffraction/project/categories/structure_view/factory.py b/src/easydiffraction/project/categories/structure_view/factory.py new file mode 100644 index 000000000..8e9ca467b --- /dev/null +++ b/src/easydiffraction/project/categories/structure_view/factory.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Factory for project structure_view categories.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.core.factory import FactoryBase + + +class StructureViewFactory(FactoryBase): + """Create project structure_view category instances.""" + + _default_rules: ClassVar[dict] = { + frozenset(): 'default', + } diff --git a/src/easydiffraction/project/categories/table/__init__.py b/src/easydiffraction/project/categories/table/__init__.py deleted file mode 100644 index 810cb5c97..000000000 --- a/src/easydiffraction/project/categories/table/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Project table category exports.""" - -from __future__ import annotations - -from easydiffraction.project.categories.table.default import Table -from easydiffraction.project.categories.table.factory import TableFactory diff --git a/src/easydiffraction/project/categories/verbosity/default.py b/src/easydiffraction/project/categories/verbosity/default.py index 09b3821c6..ad7d6b78b 100644 --- a/src/easydiffraction/project/categories/verbosity/default.py +++ b/src/easydiffraction/project/categories/verbosity/default.py @@ -6,9 +6,7 @@ from easydiffraction.core.category import CategoryItem from easydiffraction.core.metadata import TypeInfo -from easydiffraction.core.validation import AttributeSpec -from easydiffraction.core.validation import MembershipValidator -from easydiffraction.core.variable import StringDescriptor +from easydiffraction.core.variable import EnumDescriptor from easydiffraction.io.cif.handler import CifHandler from easydiffraction.project.categories.verbosity.factory import VerbosityFactory from easydiffraction.utils.enums import VerbosityEnum @@ -28,20 +26,15 @@ class Verbosity(CategoryItem): def __init__(self) -> None: super().__init__() - self._fit = StringDescriptor( + self._fit = EnumDescriptor( name='fit', + enum=VerbosityEnum, description='Fitting process output verbosity', - value_spec=AttributeSpec( - default=VerbosityEnum.default().value, - validator=MembershipValidator( - allowed=[member.value for member in VerbosityEnum], - ), - ), cif_handler=CifHandler(names=['_verbosity.fit']), ) @property - def fit(self) -> StringDescriptor: + def fit(self) -> EnumDescriptor: """Fitting process output verbosity.""" return self._fit diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index fb5983d8c..3c07970fb 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -27,6 +27,9 @@ from easydiffraction.project.project import Project +StructureViewRange = tuple[tuple[float, float], tuple[float, float], tuple[float, float]] + + _PATTERN_OPTION_DESCRIPTIONS: dict[str, str] = { 'auto': 'Show the most informative available pattern view.', 'measured': 'Measured diffraction intensities.', @@ -39,6 +42,17 @@ } +_STRUCTURE_OPTION_DESCRIPTIONS: dict[str, str] = { + 'auto': 'Show the features the structure and engine support.', + 'atoms': 'Atoms as spheres, occupancy wedges, or ADP ellipsoids.', + 'bonds': 'Bonds between atoms within the per-structure cutoffs.', + 'cell': 'Unit-cell edges.', + 'axes': 'The a/b/c axis triad.', + 'moments': 'Magnetic-moment arrows (no moment data in version 1).', + 'labels': 'Atom labels at each site.', +} + + @dataclass(frozen=True, slots=True) class PatternOptionStatus: """Availability metadata for one ``display.pattern`` option.""" @@ -132,7 +146,7 @@ def correlations( show_diagonal: bool = True, ) -> None: """Show parameter correlations from the latest fit.""" - self._project.chart.plotter.plot_param_correlations( + self._project.rendering_plot.plotter.plot_param_correlations( threshold=threshold, precision=precision, max_parameters=max_parameters, @@ -154,9 +168,9 @@ def series( another. """ if param is None: - self._project.chart.plotter.plot_all_param_series(versus=versus) + self._project.rendering_plot.plotter.plot_all_param_series(versus=versus) else: - self._project.chart.plotter.plot_param_series(param=param, versus=versus) + self._project.rendering_plot.plotter.plot_param_series(param=param, versus=versus) def help(self) -> None: """Print available fit-display methods.""" @@ -199,7 +213,7 @@ def _predictive_needs_processing_indicator( """Return whether predictive plotting still needs processing.""" analysis = self._project.analysis experiment = self._project.experiments[expt_name] - plotter = self._project.chart.plotter + plotter = self._project.rendering_plot.plotter _, x_axis_name, _, _, _ = plotter._resolve_x_axis(experiment.type, x) x_axis_name = str(x_axis_name) require_draws = plotter.engine == PlotterEngineEnum.PLOTLY.value and style in { @@ -250,7 +264,7 @@ def pairs( else nullcontext() ) with indicator_context: - self._project.chart.plotter.plot_posterior_pairs( + self._project.rendering_plot.plotter.plot_posterior_pairs( parameters=parameters, style=style, threshold=threshold, @@ -261,7 +275,7 @@ def distribution(self, param: object | None = None) -> None: """ Plot posterior distributions for one or all free parameters. """ - plotter = self._project.chart.plotter + plotter = self._project.rendering_plot.plotter if param is not None: plotter.plot_param_distribution(param) return @@ -298,7 +312,7 @@ def predictive( else nullcontext() ) with indicator_context: - self._project.chart.plotter.plot_posterior_predictive( + self._project.rendering_plot.plotter.plot_posterior_predictive( expt_name=expt_name, style=style, x_min=x_min, @@ -374,7 +388,7 @@ def pattern( else nullcontext() ) with indicator_context: - self._project.chart.plotter._plot_posterior_predictive_request( + self._project.rendering_plot.plotter._plot_posterior_predictive_request( expt_name=expt_name, style='band', plot_options=_MeasVsCalcPlotOptions( @@ -417,7 +431,7 @@ def pattern( else nullcontext() ) with indicator_context: - self._project.chart.plotter._plot_posterior_predictive_request( + self._project.rendering_plot.plotter._plot_posterior_predictive_request( expt_name=expt_name, style='band', plot_options=_MeasVsCalcPlotOptions( @@ -459,6 +473,162 @@ def show_pattern_options(self, expt_name: str) -> None: ], ) + def structure( + self, + struct_name: str, + include: str | tuple[str, ...] = 'auto', + range: StructureViewRange | None = None, + path: str | None = None, + ) -> None: + """ + Show a 3D structure view for one structure. + + Parallels :meth:`pattern`: it draws with the active + ``project.rendering_structure`` engine and displays directly (no + return value). Feature visibility is resolved per ADR section 8; + the renderer announces and skips any feature it cannot draw. + + Parameters + ---------- + struct_name : str + Name of the structure to draw. + include : str | tuple[str, ...], default='auto' + ``'auto'`` (default) resolves features from data + availability, persisted ``project.rendering_structure`` + flags, then built-in defaults; an explicit tuple of + ``atoms``/``bonds``/``cell``/``axes``/ + ``moments``/``labels`` wins outright. + range : StructureViewRange | None, default=None + Optional per-axis ``((min, max), ...)`` window overriding + the persisted ``project.rendering_structure`` range for this + call only. + path : str | None, default=None + When given, write the rendered view to this path instead of + displaying it (a standalone HTML file for the Three.js + engine). + """ + from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415 + from easydiffraction.display.structure.builder import ( # noqa: PLC0415 + structure_feature_availability, + ) + + structure = self._project.structures[struct_name] + structure._update_categories() + availability = structure_feature_availability( + structure, style=self._project.structure_style + ) + features = self._resolve_structure_features(include, availability) + window = range if range is not None else self._project.structure_view.view_range() + scene = build_scene( + structure, + style=self._project.structure_style, + view_range=window, + features=features, + ) + output = self._project.rendering_structure.viewer.render(scene, features=features) + if path is not None: + import pathlib # noqa: PLC0415 + + pathlib.Path(path).write_text(output, encoding='utf-8') + return + atom_view = self._project.structure_style.atom_view.value + console.paragraph(f"Structure 🧩 '{struct_name}' (Atom view type: '{atom_view}')") + self._emit_structure_output(output) + + def show_structure_options(self, struct_name: str) -> None: + """ + Show available ``structure(include=...)`` options. + """ + from easydiffraction.display.structure.builder import ( # noqa: PLC0415 + structure_feature_availability, + ) + + structure = self._project.structures[struct_name] + structure._update_categories() + availability = structure_feature_availability( + structure, style=self._project.structure_style + ) + supported = self._project.rendering_structure.viewer.supported_features() + auto = self._resolve_structure_features('auto', availability) + + rows = [] + for option in ('atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'): + in_data = option in availability.available + in_engine = option in supported + rows.append([ + option, + _STRUCTURE_OPTION_DESCRIPTIONS[option], + 'yes' if (in_data and in_engine) else 'no', + 'yes' if (option in auto and in_engine) else 'no', + ]) + render_table( + columns_headers=['Option', 'Description', 'Available', 'Auto'], + columns_alignment=['left', 'left', 'center', 'center'], + columns_data=rows, + ) + if availability.radius_substitutions: + console.paragraph('Radius substitutions (fell back to covalent)') + console.print(', '.join(availability.radius_substitutions)) + + def _resolve_structure_features( + self, + include: str | tuple[str, ...], + availability: object, + ) -> frozenset[str]: + """ + Resolve the concrete feature set per ADR section 8 precedence. + """ + normalized = self._normalize_structure_include(include) + if normalized != ('auto',): + return frozenset(normalized) + view = self._project.structure_view + resolved = {f for f in ('atoms', 'bonds', 'cell', 'axes') if f in availability.available} + if 'labels' in availability.available and view.show_labels.value: + resolved.add('labels') + if 'moments' in availability.available and view.show_moments.value: + resolved.add('moments') + return frozenset(resolved) + + @staticmethod + def _normalize_structure_include(include: str | tuple[str, ...]) -> tuple[str, ...]: + """Validate and normalize a ``structure(include=...)`` value.""" + values = (include,) if isinstance(include, str) else include + if not values: + msg = 'include must contain at least one option.' + raise ValueError(msg) + normalized = tuple(dict.fromkeys(values)) + unknown = [value for value in normalized if value not in _STRUCTURE_OPTION_DESCRIPTIONS] + if unknown: + msg = f'Unknown structure include option(s): {unknown}.' + raise ValueError(msg) + if 'auto' in normalized and len(normalized) > 1: + msg = "include='auto' cannot be combined with other options." + raise ValueError(msg) + return normalized + + def _emit_structure_output(self, output: str) -> None: + """Display ASCII text in the console or HTML in a notebook.""" + from easydiffraction.display.structure.enums import ViewerEngineEnum # noqa: PLC0415 + from easydiffraction.utils.environment import in_jupyter # noqa: PLC0415 + + if self._project.rendering_structure.viewer.engine == ViewerEngineEnum.ASCII.value: + # Built-in print keeps the renderer's raw ANSI colour + # codes (Jupyter and terminals interpret them); Rich's + # console.print would escape and garble them. Mirrors + # the ASCII pattern plotter. + print(output) + return + if in_jupyter(): + from IPython.display import HTML # noqa: PLC0415 + from IPython.display import display # noqa: PLC0415 + + display(HTML(output)) + return + console.print( + 'Three.js structure view generated as HTML. Pass path=... to save it, ' + "or set project.rendering_structure.type = 'ascii' for a terminal view.", + ) + @staticmethod def _normalize_include(include: str | tuple[str, ...]) -> tuple[str, ...]: """Validate and normalize a ``pattern(include=...)`` value.""" @@ -591,7 +761,7 @@ def _show_point_estimate_pattern( self._validate_requested_include(statuses, include) include_set = set(include) if include_set == {'measured'}: - self._project.chart.plotter.plot_meas( + self._project.rendering_plot.plotter.plot_meas( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -600,7 +770,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'measured', 'excluded'}: - self._project.chart.plotter.plot_meas( + self._project.rendering_plot.plotter.plot_meas( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -609,7 +779,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'calculated'}: - self._project.chart.plotter.plot_calc( + self._project.rendering_plot.plotter.plot_calc( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -618,7 +788,7 @@ def _show_point_estimate_pattern( ) return if include_set == {'calculated', 'excluded'}: - self._project.chart.plotter.plot_calc( + self._project.rendering_plot.plotter.plot_calc( expt_name=expt_name, x_min=x_min, x_max=x_max, @@ -627,7 +797,7 @@ def _show_point_estimate_pattern( ) return if {'measured', 'calculated'}.issubset(include_set): - self._project.chart.plotter._plot_meas_vs_calc_request( + self._project.rendering_plot.plotter._plot_meas_vs_calc_request( expt_name=expt_name, plot_options=_MeasVsCalcPlotOptions( x_min=x_min, @@ -649,7 +819,7 @@ def _show_point_estimate_pattern( def _pattern_option_statuses(self, expt_name: str) -> list[PatternOptionStatus]: """Return availability details for the requested experiment.""" - self._project.chart.plotter._update_project_categories(expt_name) + self._project.rendering_plot.plotter._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] pattern = intensity_category_for(experiment) sample_form = experiment.type.sample_form.value @@ -873,9 +1043,9 @@ def _uncertainty_status( if not posterior_predictive: return False, 'Posterior predictive data is unavailable.' - active_chart_engine = getattr(self._project.chart.plotter, 'engine', None) + active_chart_engine = getattr(self._project.rendering_plot.plotter, 'engine', None) if active_chart_engine is None: - active_chart_engine = self._project.chart.type + active_chart_engine = self._project.rendering_plot.type if active_chart_engine != PlotterEngineEnum.PLOTLY.value: return False, 'Uncertainty bands currently require the Plotly chart engine.' diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index c1c42eadb..6cffbd76a 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -23,8 +23,6 @@ from easydiffraction.io.cif.serialize import project_to_cif from easydiffraction.io.results_sidecar import read_analysis_results_sidecar from easydiffraction.io.results_sidecar import write_analysis_results_sidecar -from easydiffraction.project.categories.publication import Publication -from easydiffraction.project.categories.publication import PublicationFactory from easydiffraction.project.display import ProjectDisplay from easydiffraction.project.project_config import ProjectConfig from easydiffraction.utils.enums import VerbosityEnum @@ -36,8 +34,11 @@ if TYPE_CHECKING: from collections.abc import Callable - from easydiffraction.project.categories.chart import Chart - from easydiffraction.project.categories.table import Table + from easydiffraction.project.categories.rendering_plot import RenderingPlot + from easydiffraction.project.categories.rendering_structure import RenderingStructure + from easydiffraction.project.categories.rendering_table import RenderingTable + from easydiffraction.project.categories.structure_style import StructureStyle + from easydiffraction.project.categories.structure_view import StructureView from easydiffraction.project.categories.verbosity import Verbosity from easydiffraction.project.project_info import ProjectInfo from easydiffraction.report import Report @@ -207,11 +208,13 @@ def __init__( object.__setattr__(self, '_info', self._config.info) self._structures = Structures() self._experiments = Experiments() - object.__setattr__(self, '_chart', self._config.chart) - object.__setattr__(self, '_table', self._config.table) + object.__setattr__(self, '_rendering_plot', self._config.rendering_plot) + object.__setattr__(self, '_rendering_table', self._config.rendering_table) object.__setattr__(self, '_verbosity', self._config.verbosity) + object.__setattr__(self, '_rendering_structure', self._config.rendering_structure) + object.__setattr__(self, '_structure_view', self._config.structure_view) + object.__setattr__(self, '_structure_style', self._config.structure_style) object.__setattr__(self, '_report', self._config.report) - self._publication = PublicationFactory.create(PublicationFactory.default_tag()) self._display = ProjectDisplay(self) self._analysis = Analysis(self) self._saved = False @@ -224,10 +227,12 @@ def _attach_category_parents(self) -> None: self._structures._parent = self self._experiments._parent = self self._analysis._parent = self - self._chart._parent = self - self._table._parent = self + self._rendering_plot._parent = self + self._rendering_table._parent = self + self._rendering_structure._parent = self + self._structure_view._parent = self + self._structure_style._parent = self self._report._parent = self - self._publication._parent = self @staticmethod def _supported_filters_for(category: object) -> dict[str, object]: @@ -235,13 +240,17 @@ def _supported_filters_for(category: object) -> dict[str, object]: del category return {} - def _swap_chart(self, new_type: str, *, strict: bool = True) -> None: + def _swap_rendering_plot(self, new_type: str, *, strict: bool = True) -> None: """Switch the active chart renderer.""" - self._chart._set_type(new_type, strict=strict) + self._rendering_plot._set_type(new_type, strict=strict) - def _swap_table(self, new_type: str, *, strict: bool = True) -> None: + def _swap_rendering_table(self, new_type: str, *, strict: bool = True) -> None: """Switch the active table renderer.""" - self._table._set_type(new_type, strict=strict) + self._rendering_table._set_type(new_type, strict=strict) + + def _swap_rendering_structure(self, new_type: str, *, strict: bool = True) -> None: + """Switch the active structure-view renderer.""" + self._rendering_structure._set_type(new_type, strict=strict) @classmethod def current_project_path(cls) -> pathlib.Path | None: @@ -313,14 +322,29 @@ def experiments(self, experiments: Experiments) -> None: self._experiments = experiments @property - def chart(self) -> Chart: + def rendering_plot(self) -> RenderingPlot: """Chart configuration bound to the project.""" - return self._chart + return self._rendering_plot @property - def table(self) -> Table: + def rendering_table(self) -> RenderingTable: """Table configuration bound to the project.""" - return self._table + return self._rendering_table + + @property + def rendering_structure(self) -> RenderingStructure: + """Structure-view configuration bound to the project.""" + return self._rendering_structure + + @property + def structure_view(self) -> StructureView: + """Structure-view content and region bound to the project.""" + return self._structure_view + + @property + def structure_style(self) -> StructureStyle: + """Structure-view appearance bound to the project.""" + return self._structure_style @property def display(self) -> ProjectDisplay: @@ -337,11 +361,6 @@ def report(self) -> Report: """Submission report builder bound to the project.""" return self._report - @property - def publication(self) -> Publication: - """Publication metadata bound to the project.""" - return self._publication - @property def parameters(self) -> list: """Return parameters from all structures and experiments.""" diff --git a/src/easydiffraction/project/project_config.py b/src/easydiffraction/project/project_config.py index 2c779c8a7..a8f13af08 100644 --- a/src/easydiffraction/project/project_config.py +++ b/src/easydiffraction/project/project_config.py @@ -5,14 +5,20 @@ from __future__ import annotations from easydiffraction.core.category_owner import CategoryOwner -from easydiffraction.project.categories.chart import Chart -from easydiffraction.project.categories.chart import ChartFactory from easydiffraction.project.categories.info import ProjectInfo from easydiffraction.project.categories.info import ProjectInfoFactory +from easydiffraction.project.categories.rendering_plot import RenderingPlot +from easydiffraction.project.categories.rendering_plot import RenderingPlotFactory +from easydiffraction.project.categories.rendering_structure import RenderingStructure +from easydiffraction.project.categories.rendering_structure import RenderingStructureFactory +from easydiffraction.project.categories.rendering_table import RenderingTable +from easydiffraction.project.categories.rendering_table import RenderingTableFactory from easydiffraction.project.categories.report import Report from easydiffraction.project.categories.report import ReportFactory -from easydiffraction.project.categories.table import Table -from easydiffraction.project.categories.table import TableFactory +from easydiffraction.project.categories.structure_style import StructureStyle +from easydiffraction.project.categories.structure_style import StructureStyleFactory +from easydiffraction.project.categories.structure_view import StructureView +from easydiffraction.project.categories.structure_view import StructureViewFactory from easydiffraction.project.categories.verbosity import Verbosity from easydiffraction.project.categories.verbosity import VerbosityFactory @@ -33,10 +39,15 @@ def __init__( title=title, description=description, ) - self._chart = ChartFactory.create(ChartFactory.default_tag()) + self._rendering_plot = RenderingPlotFactory.create(RenderingPlotFactory.default_tag()) self._report = ReportFactory.create(ReportFactory.default_tag()) - self._table = TableFactory.create(TableFactory.default_tag()) + self._rendering_table = RenderingTableFactory.create(RenderingTableFactory.default_tag()) self._verbosity = VerbosityFactory.create(VerbosityFactory.default_tag()) + self._rendering_structure = RenderingStructureFactory.create( + RenderingStructureFactory.default_tag() + ) + self._structure_view = StructureViewFactory.create(StructureViewFactory.default_tag()) + self._structure_style = StructureStyleFactory.create(StructureStyleFactory.default_tag()) @property def info(self) -> ProjectInfo: @@ -44,9 +55,9 @@ def info(self) -> ProjectInfo: return self._info @property - def chart(self) -> Chart: + def rendering_plot(self) -> RenderingPlot: """Chart configuration category.""" - return self._chart + return self._rendering_plot @property def report(self) -> Report: @@ -54,15 +65,30 @@ def report(self) -> Report: return self._report @property - def table(self) -> Table: + def rendering_table(self) -> RenderingTable: """Table configuration category.""" - return self._table + return self._rendering_table @property def verbosity(self) -> Verbosity: """Verbosity configuration category.""" return self._verbosity + @property + def rendering_structure(self) -> RenderingStructure: + """Structure-view configuration category.""" + return self._rendering_structure + + @property + def structure_view(self) -> StructureView: + """Structure-view content and region category.""" + return self._structure_view + + @property + def structure_style(self) -> StructureStyle: + """Structure-view appearance category.""" + return self._structure_style + @property def as_cif(self) -> str: """Serialize singleton project categories to CIF.""" diff --git a/src/easydiffraction/project/publication_loader.py b/src/easydiffraction/project/publication_loader.py deleted file mode 100644 index 592b17411..000000000 --- a/src/easydiffraction/project/publication_loader.py +++ /dev/null @@ -1,199 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Load publication metadata from TOML or JSON files.""" - -from __future__ import annotations - -import json -import pathlib -import tomllib -from collections.abc import Mapping -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from easydiffraction.project.categories.publication import Publication - -_FIELD_MAP = { - 'journal_name_full': ('journal', 'name_full'), - 'journal_year': ('journal', 'year'), - 'journal_volume': ('journal', 'volume'), - 'journal_issue': ('journal', 'issue'), - 'journal_page_first': ('journal', 'page_first'), - 'journal_page_last': ('journal', 'page_last'), - 'journal_paper_category': ('journal', 'paper_category'), - 'journal_paper_doi': ('journal', 'paper_doi'), - 'journal_coden_astm': ('journal', 'coden_astm'), - 'journal_suppl_publ_number': ('journal', 'suppl_publ_number'), - 'journal_date_accepted': ('journal_date', 'accepted'), - 'journal_date_from_coeditor': ('journal_date', 'from_coeditor'), - 'journal_date_printers_final': ('journal_date', 'printers_final'), - 'journal_coeditor_code': ('journal_coeditor', 'code'), - 'journal_coeditor_name': ('journal_coeditor', 'name'), - 'journal_coeditor_notes': ('journal_coeditor', 'notes'), - 'contact_author_name': ('contact_author', 'name'), - 'contact_author_address': ('contact_author', 'address'), - 'contact_author_email': ('contact_author', 'email'), - 'contact_author_phone': ('contact_author', 'phone'), - 'contact_author_id_orcid': ('contact_author', 'id_orcid'), - 'contact_author_id_iucr': ('contact_author', 'id_iucr'), - 'body_title': ('body', 'title'), - 'body_synopsis': ('body', 'synopsis'), - 'body_abstract': ('body', 'abstract'), -} - -_AUTHOR_FIELDS = { - 'name', - 'address', - 'footnote', - 'id_orcid', - 'id_iucr', -} - - -def _read_publication_data(path: pathlib.Path) -> Mapping[str, object]: - """Read a publication metadata file by extension.""" - ext = path.suffix.lower() - if ext == '.toml': - data = tomllib.loads(path.read_text(encoding='utf-8')) - elif ext == '.json': - data = json.loads(path.read_text(encoding='utf-8')) - else: - msg = f'Unsupported publication-info format: {ext}. Use .toml or .json.' - raise ValueError(msg) - - if not isinstance(data, Mapping): - msg = 'Publication-info file must contain a top-level object.' - raise TypeError(msg) - return data - - -def _optional_text(key: str, value: object) -> str | None: - """Validate an optional text field from the publication file.""" - if value is None: - return None - if isinstance(value, str): - return value - if isinstance(value, (int, float)) and not isinstance(value, bool): - return str(value) - msg = f'Publication-info field {key!r} must be a string or null.' - raise ValueError(msg) - - -def _required_text(key: str, value: object) -> str: - """Validate a required text field from the publication file.""" - text = _optional_text(key, value) - if text is None or not text: - msg = f'Publication-info field {key!r} must be a non-empty string.' - raise ValueError(msg) - return text - - -def _keywords(value: object) -> list[str]: - """Validate body keywords from the publication file.""" - if value is None: - return [] - if not isinstance(value, list): - msg = "Publication-info field 'body_keywords' must be a list of strings." - raise TypeError(msg) - - keywords: list[str] = [] - for idx, keyword in enumerate(value): - if not isinstance(keyword, str): - msg = f"Publication-info field 'body_keywords[{idx}]' must be a string." - raise TypeError(msg) - keywords.append(keyword) - return keywords - - -def _author_rows(value: object) -> list[dict[str, str | None]]: - """Validate publication author rows from the publication file.""" - if not isinstance(value, list): - msg = "Publication-info field 'authors' must be a list of objects." - raise TypeError(msg) - - rows: list[dict[str, str | None]] = [] - for idx, row in enumerate(value): - if not isinstance(row, Mapping): - msg = f"Publication-info field 'authors[{idx}]' must be an object." - raise TypeError(msg) - - for key in row: - if key not in _AUTHOR_FIELDS: - msg = f'authors.{key}' - raise ValueError(msg) - - rows.append({ - 'name': _required_text(f'authors[{idx}].name', row.get('name')), - 'address': _optional_text(f'authors[{idx}].address', row.get('address')), - 'footnote': _optional_text(f'authors[{idx}].footnote', row.get('footnote')), - 'id_orcid': _optional_text(f'authors[{idx}].id_orcid', row.get('id_orcid')), - 'id_iucr': _optional_text(f'authors[{idx}].id_iucr', row.get('id_iucr')), - }) - return rows - - -def _validate_publication_data( - data: Mapping[str, object], -) -> tuple[ - list[tuple[str, str, str | None]], - list[str] | None, - list[dict[str, str | None]] | None, -]: - """Validate publication data and return normalized updates.""" - updates: list[tuple[str, str, str | None]] = [] - keywords: list[str] | None = None - authors: list[dict[str, str | None]] | None = None - - for key, value in data.items(): - if key in _FIELD_MAP: - category_name, attr_name = _FIELD_MAP[key] - updates.append((category_name, attr_name, _optional_text(key, value))) - elif key == 'body_keywords': - keywords = _keywords(value) - elif key == 'authors': - authors = _author_rows(value) - else: - raise ValueError(key) - - return updates, keywords, authors - - -def _apply_author_rows( - publication: Publication, - authors: list[dict[str, str | None]], -) -> None: - """ - Replace the publication author collection with normalized rows. - """ - publication.authors._adopt_items([]) - publication.authors._mark_parent_dirty() - for author in authors: - publication.authors.add( - name=author['name'], - address=author['address'], - footnote=author['footnote'], - id_orcid=author['id_orcid'], - id_iucr=author['id_iucr'], - ) - - -def load_publication(publication: Publication, path: str | pathlib.Path) -> None: - """ - Load publication metadata into a Publication object. - - Parameters - ---------- - publication : Publication - Publication metadata facade to populate. - path : str | pathlib.Path - TOML or JSON file containing flat publication metadata keys. - """ - data = _read_publication_data(pathlib.Path(path)) - updates, keywords, authors = _validate_publication_data(data) - - for category_name, attr_name, value in updates: - setattr(getattr(publication, category_name), attr_name, value) - if keywords is not None: - publication.body.keywords = keywords - if authors is not None: - _apply_author_rows(publication, authors) diff --git a/src/easydiffraction/report/data_context.py b/src/easydiffraction/report/data_context.py index 741c77a0e..ce6fe4ca9 100644 --- a/src/easydiffraction/report/data_context.py +++ b/src/easydiffraction/report/data_context.py @@ -57,48 +57,6 @@ 'ambient_temperature', 'ambient_pressure', ) -_PUBLICATION_JOURNAL_FIELDS = ( - 'name_full', - 'year', - 'volume', - 'issue', - 'page_first', - 'page_last', - 'paper_category', - 'paper_doi', - 'coden_astm', - 'suppl_publ_number', -) -_PUBLICATION_JOURNAL_DATE_FIELDS = ( - 'accepted', - 'from_coeditor', - 'printers_final', -) -_PUBLICATION_JOURNAL_COEDITOR_FIELDS = ( - 'code', - 'name', - 'notes', -) -_PUBLICATION_CONTACT_AUTHOR_FIELDS = ( - 'name', - 'address', - 'email', - 'phone', - 'id_orcid', - 'id_iucr', -) -_PUBLICATION_BODY_FIELDS = ( - 'title', - 'synopsis', - 'abstract', -) -_PUBLICATION_AUTHOR_FIELDS = ( - 'name', - 'address', - 'footnote', - 'id_orcid', - 'id_iucr', -) _REPORT_LOOP_DISPLAY_LIMIT = DEFAULT_LOOP_DISPLAY_LIMIT _FULL_WIDTH_TABLE_CHAR_LIMIT = 40 _TRUNCATED_DATA_CATEGORY_CODES = frozenset({'pd_data', 'total_data'}) @@ -126,8 +84,7 @@ class ReportDataContext: Parameters ---------- project : object - Project facade that owns structures, experiments, analysis, and - publication metadata. + Project facade that owns structures, experiments, and analysis. """ def __init__(self, project: object) -> None: @@ -151,7 +108,6 @@ def build(self) -> dict[str, object]: 'structures': [self._structure_context(structure) for structure in structures], 'experiments': [self._experiment_context(experiment) for experiment in experiments], 'analysis': self._analysis_context(), - 'publication': self._publication_context(), 'metadata': { 'easydiffraction_version': package_version('easydiffraction'), 'generated_at': _format_generated_at(datetime.now(tz=UTC)), @@ -312,37 +268,6 @@ def _software_context(self) -> dict[str, object]: 'fit_datetime': _attr_value(software, 'timestamp'), } - def _publication_context(self) -> dict[str, object]: - """Return journal-publication metadata.""" - publication = _safe_attr(self._project, 'publication') - body = _safe_attr(publication, 'body') - return { - 'journal': _field_values( - _safe_attr(publication, 'journal'), - _PUBLICATION_JOURNAL_FIELDS, - ), - 'journal_date': _field_values( - _safe_attr(publication, 'journal_date'), - _PUBLICATION_JOURNAL_DATE_FIELDS, - ), - 'journal_coeditor': _field_values( - _safe_attr(publication, 'journal_coeditor'), - _PUBLICATION_JOURNAL_COEDITOR_FIELDS, - ), - 'contact_author': _field_values( - _safe_attr(publication, 'contact_author'), - _PUBLICATION_CONTACT_AUTHOR_FIELDS, - ), - 'body': { - **_field_values(body, _PUBLICATION_BODY_FIELDS), - 'keywords': list(_safe_attr(body, 'keywords') or []), - }, - 'authors': [ - _field_values(author, _PUBLICATION_AUTHOR_FIELDS) - for author in _collection_values(_safe_attr(publication, 'authors')) - ], - } - def build_report_data_context(project: object) -> dict[str, object]: """ diff --git a/src/easydiffraction/report/fit_plot.py b/src/easydiffraction/report/fit_plot.py index fca30f9ad..1f60fc677 100644 --- a/src/easydiffraction/report/fit_plot.py +++ b/src/easydiffraction/report/fit_plot.py @@ -21,11 +21,14 @@ from easydiffraction.display.plotters.plotly import COMPOSITE_MARGIN_TOP from easydiffraction.display.plotters.plotly import COMPOSITE_VERTICAL_SPACING from easydiffraction.display.plotters.plotly import DEFAULT_COLORS +from easydiffraction.display.plotters.plotly import DIAGONAL_LINE_RGB from easydiffraction.display.plotters.plotly import DISPLAY_TICK_FRACTIONS from easydiffraction.display.plotters.plotly import MAIN_INTENSITY_RANGE_MARGIN_FRACTION from easydiffraction.display.plotters.plotly import MEASURED_LINE_WIDTH from easydiffraction.display.plotters.plotly import PLOTLY_HEIGHT_PER_UNIT from easydiffraction.display.plotters.plotly import RESIDUAL_LINE_WIDTH +from easydiffraction.display.plotters.plotly import single_crystal_axis_range +from easydiffraction.display.plotters.plotly import single_crystal_tick_step from easydiffraction.display.plotting import DEFAULT_RESIDUAL_HEIGHT_FRACTION from easydiffraction.report.style import REPORT_AXIS_RGB from easydiffraction.report.style import REPORT_CHART_GRID_RGB @@ -148,6 +151,7 @@ def fit_plot_axis_styles() -> dict[str, str]: return { 'axis_rgb': _style_rgb_channels(REPORT_AXIS_RGB), 'grid_rgb': _style_rgb_channels(REPORT_CHART_GRID_RGB), + 'diag_rgb': _style_rgb_channels(DIAGONAL_LINE_RGB), } @@ -160,28 +164,29 @@ def fit_scatter_geometry() -> dict[str, float]: def fit_scatter_ranges(fit_data: dict[str, Any]) -> dict[str, float]: - """Return x/y ranges and the y=x diagonal span for an SC scatter.""" + """ + Return the shared x/y range, tick step, and y=x diagonal span. + + The x and y axes share one range (computed across the calculated + values and the measured values widened by their uncertainties) so + the diagonal is a true y=x line and the ticks can match. + """ x_values = _numeric_values(fit_data['x']['values']) meas = fit_data['series']['meas'] y_values = _numeric_values(meas['values']) su = meas.get('su') - if su is not None: - su_values = _numeric_values(su) - y_low = [value - error for value, error in zip(y_values, su_values, strict=True)] - y_high = [value + error for value, error in zip(y_values, su_values, strict=True)] - else: - y_low = y_values - y_high = y_values - - x_min, x_max = _padded_range(*_data_range([x_values])) - y_min, y_max = _padded_range(*_data_range([y_low, y_high])) + su_values = _numeric_values(su) if su is not None else None + + axis_min, axis_max = single_crystal_axis_range(x_values, y_values, su_values) + tick_step = single_crystal_tick_step(axis_min, axis_max) return { - 'x_min': x_min, - 'x_max': x_max, - 'y_min': y_min, - 'y_max': y_max, - 'diag_min': min(x_min, y_min), - 'diag_max': max(x_max, y_max), + 'x_min': axis_min, + 'x_max': axis_max, + 'y_min': axis_min, + 'y_max': axis_max, + 'diag_min': axis_min, + 'diag_max': axis_max, + 'tick_step': tick_step, } @@ -296,14 +301,6 @@ def _style_rgb_channels(rgb: tuple[int, int, int]) -> str: return ','.join(str(channel) for channel in rgb) -def _padded_range(minimum: float, maximum: float) -> tuple[float, float]: - """Return a range padded by the main-intensity margin fraction.""" - margin = max(maximum - minimum, 0.0) * MAIN_INTENSITY_RANGE_MARGIN_FRACTION - if margin <= 0.0: - margin = 1.0 - return minimum - margin, maximum + margin - - def _data_range(series_list: list[list[float]]) -> tuple[float, float]: values = [value for series in series_list for value in series] if not values: diff --git a/src/easydiffraction/report/html_renderer.py b/src/easydiffraction/report/html_renderer.py index dc557a60e..b7080b49c 100644 --- a/src/easydiffraction/report/html_renderer.py +++ b/src/easydiffraction/report/html_renderer.py @@ -63,6 +63,7 @@ def render_html_report( context: dict[str, object], *, offline: bool = False, + project: object | None = None, ) -> str: """ Render a report data context as HTML. @@ -73,6 +74,9 @@ def render_html_report( Data returned by ``Report.data_context()``. offline : bool, default=False Whether Plotly figures should embed JavaScript assets. + project : object | None, default=None + Live project used to build interactive 3D structure figures. + When ``None``, the report omits structure views. Returns ------- @@ -87,6 +91,9 @@ def render_html_report( context, offline=offline, ) + template_context['structure_figures'] = ( + _structure_figure_html_context(project, offline=offline) if project is not None else {} + ) return _environment().get_template(_TEMPLATE_NAME).render(**template_context) @@ -119,7 +126,7 @@ def save_html_report( output_path = html_report_path(project, path) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text( - render_html_report(context, offline=offline), + render_html_report(context, offline=offline, project=project), encoding='utf-8', ) if offline: @@ -175,7 +182,7 @@ def _fit_figure_html_context( if fit_data is None: continue experiment_id = str(experiment.get('id') or 'experiment') - figure = _fit_data_figure(experiment_id, fit_data, experiment) + figure = _fit_data_figure(experiment_id, fit_data) rendered[experiment_id] = _figure_html( figure, include_plotlyjs=include_plotlyjs, @@ -185,10 +192,46 @@ def _fit_figure_html_context( return rendered +def _structure_figure_html_context( + project: object, + *, + offline: bool, +) -> dict[str, str]: + """ + Return interactive structure-view HTML snippets by structure name. + """ + from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415 + from easydiffraction.display.structure.builder import ( # noqa: PLC0415 + structure_feature_availability, + ) + from easydiffraction.display.structure.renderers.threejs import ( # noqa: PLC0415 + ThreeJsStructureRenderer, + ) + + renderer = ThreeJsStructureRenderer() + window = project.structure_view.view_range() + rendered: dict[str, str] = {} + for structure in project.structures.values(): + availability = structure_feature_availability(structure, style=project.structure_style) + features = project.display._resolve_structure_features('auto', availability) + scene = build_scene( + structure, + style=project.structure_style, + view_range=window, + features=features, + ) + rendered[str(structure.name)] = renderer.render( + scene, + features=features, + offline=offline, + dark=False, + ) + return rendered + + def _fit_data_figure( experiment_id: str, fit_data: dict[str, object], - experiment: dict[str, object], ) -> object: """Build a Plotly fit figure from one fit-data payload.""" x_data = fit_data['x'] @@ -230,7 +273,7 @@ def _fit_data_figure( y_resid=np.asarray(y_diff, dtype=float), bragg_tick_sets=tuple(fit_data.get('bragg_tick_sets') or ()), axes_labels=list(fit_data.get('axes_labels') or [_axis_title(x_data), 'Intensity']), - title=_fit_figure_title(experiment_id, experiment), + title=_fit_figure_title(experiment_id), residual_height_fraction=DEFAULT_RESID_HEIGHT, bragg_peaks_height_fraction=DEFAULT_BRAGG_ROW, y_bkg=np.asarray(y_bkg, dtype=float) if y_bkg is not None else None, @@ -259,25 +302,13 @@ def _single_crystal_fit_data_figure( y_meas=y_meas_array, y_meas_su=y_meas_su_array, axes_labels=list(fit_data.get('axes_labels') or ['I²calc', 'I²meas']), - title=f"Measured vs Calculated data for experiment 🔬 '{experiment_id}'", + title=_fit_figure_title(experiment_id), ) -def _fit_figure_title(experiment_id: str, experiment: dict[str, object]) -> str: +def _fit_figure_title(experiment_id: str) -> str: """Return a report title matching the direct plotting API.""" - experiment_type = experiment.get('type') - if _is_powder_bragg_context(experiment_type): - return f"Measured vs Calculated data for experiment 🔬 '{experiment_id}'" - return f'Measured vs calculated: {experiment_id}' - - -def _is_powder_bragg_context(experiment_type: object) -> bool: - if not isinstance(experiment_type, dict): - return False - return ( - experiment_type.get('sample_form') == 'powder' - and experiment_type.get('scattering_type') == 'bragg' - ) + return f"Diffraction pattern for experiment 🔬 '{experiment_id}'" def _figure_html( diff --git a/src/easydiffraction/report/style.py b/src/easydiffraction/report/style.py index f88b27c6a..ab1ddae01 100644 --- a/src/easydiffraction/report/style.py +++ b/src/easydiffraction/report/style.py @@ -4,8 +4,10 @@ from __future__ import annotations +from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR + REPORT_AXIS_RGB = (190, 199, 208) -REPORT_TABLE_INNER_RGB = (217, 223, 228) +REPORT_TABLE_INNER_HEX = LIGHT_AXIS_FRAME_COLOR REPORT_CHART_GRID_RGB = (235, 240, 248) REPORT_ROW_RGB = (235, 240, 248) REPORT_LINK_RGB = (36, 90, 155) # mirrors --link (#245a9b) in html/style.css @@ -20,8 +22,8 @@ def report_style_context() -> dict[str, object]: return { 'axis_hex': _rgb_hex(REPORT_AXIS_RGB), 'axis_rgb': _rgb_channels(REPORT_AXIS_RGB), - 'grid_hex': _rgb_hex(REPORT_TABLE_INNER_RGB), - 'grid_rgb': _rgb_channels(REPORT_TABLE_INNER_RGB), + 'grid_hex': REPORT_TABLE_INNER_HEX, + 'grid_rgb': _hex_channels(REPORT_TABLE_INNER_HEX), 'chart_grid_hex': _rgb_hex(REPORT_CHART_GRID_RGB), 'chart_grid_rgb': _rgb_channels(REPORT_CHART_GRID_RGB), 'row_hex': _rgb_hex(REPORT_ROW_RGB), @@ -43,3 +45,12 @@ def _rgb_hex(rgb: tuple[int, int, int]) -> str: def _rgb_channels(rgb: tuple[int, int, int]) -> str: """Return comma-separated channels for TeX RGB definitions.""" return ','.join(str(channel) for channel in rgb) + + +def _hex_channels(color: str) -> str: + """Return comma-separated RGB channels for a CSS hex color.""" + return _rgb_channels(( + int(color[1:3], 16), + int(color[3:5], 16), + int(color[5:7], 16), + )) diff --git a/src/easydiffraction/report/templates/html/report.html.j2 b/src/easydiffraction/report/templates/html/report.html.j2 index f380782d2..1f9069788 100644 --- a/src/easydiffraction/report/templates/html/report.html.j2 +++ b/src/easydiffraction/report/templates/html/report.html.j2 @@ -140,6 +140,10 @@

{{ structure.id }}

+ {% if structure_figures[structure.id] %} +
{{ structure_figures[structure.id] | safe }}
+ {% endif %} + {% for category in structure.categories %}

{{ category.title }}

{% if category.kind == "item" %} @@ -201,7 +205,6 @@

{{ experiment.id }}

{% if fit_figures[experiment.id] %} -

Fit quality

{{ fit_figures[experiment.id] | safe }}
{% endif %} diff --git a/src/easydiffraction/report/templates/tex/figure_sc.tex.j2 b/src/easydiffraction/report/templates/tex/figure_sc.tex.j2 index 8d6d1b169..2e2d0dfac 100644 --- a/src/easydiffraction/report/templates/tex/figure_sc.tex.j2 +++ b/src/easydiffraction/report/templates/tex/figure_sc.tex.j2 @@ -9,6 +9,7 @@ \definecolor{ {{- style.color_name -}} }{RGB}{ {{- style.rgb -}} } \definecolor{edAxis}{RGB}{ {{- axis_styles.axis_rgb -}} } \definecolor{edGrid}{RGB}{ {{- axis_styles.grid_rgb -}} } +\definecolor{edDiag}{RGB}{ {{- axis_styles.diag_rgb -}} } {% set axes_labels = fit_data.axes_labels | default(["Icalc", "Imeas"], true) -%} {% set x_label = axes_labels[0] | tex_axis_label -%} {% set y_label = axes_labels[1] | tex_axis_label -%} @@ -22,6 +23,8 @@ xmin={{ ranges.x_min | tex_number }}, xmax={{ ranges.x_max | tex_number }}, ymin={{ ranges.y_min | tex_number }}, ymax={{ ranges.y_max | tex_number }}, +xtick distance={{ ranges.tick_step | tex_number }}, +ytick distance={{ ranges.tick_step | tex_number }}, xlabel={ {{ x_label }} }, ylabel={ {{ y_label }} }, axis line style={draw=edAxis}, @@ -32,7 +35,7 @@ major grid style={draw=edGrid, line width=0.4pt}, xmajorgrids=true, ymajorgrids=true, ] -\addplot[domain={{ ranges.diag_min | tex_number }}:{{ ranges.diag_max | tex_number }}, samples=2, color=edAxis, line width=0.4pt, forget plot] {x}; +\addplot[domain={{ ranges.diag_min | tex_number }}:{{ ranges.diag_max | tex_number }}, samples=2, color=edDiag, line width=0.4pt, forget plot] {x}; \addplot[only marks, mark=*, mark size={{ style.marker_size_pt | tex_number }}pt, color={{ style.color_name }}, mark options={fill={{ style.color_name }}, draw={{ style.color_name }}, line width={{ style.marker_line_width_pt | tex_number }}pt}, error bars/.cd, y dir=both, y explicit] table[x={ {{- fit_csv.x -}} }, y={ {{- fit_csv.meas -}} }, y error={ {{- fit_csv.meas_su -}} }, col sep=comma] { {{- csv_filename -}} }; \end{axis} \end{tikzpicture} diff --git a/src/easydiffraction/report/templates/tex/report.tex.j2 b/src/easydiffraction/report/templates/tex/report.tex.j2 index 7e0c997d6..548dfbcd4 100644 --- a/src/easydiffraction/report/templates/tex/report.tex.j2 +++ b/src/easydiffraction/report/templates/tex/report.tex.j2 @@ -7,7 +7,7 @@ % ==================================================================== \documentclass[11pt]{article} -\usepackage[margin=2.5cm]{geometry} +\usepackage[margin=2cm]{geometry} % -------------------------------------------------------------------- % Packages @@ -41,6 +41,7 @@ \definecolor{rowshade}{RGB}{ {{- report_style.row_rgb -}} } \definecolor{tableborder}{RGB}{ {{- report_style.axis_rgb -}} } \definecolor{linkblue}{RGB}{ {{- report_style.link_rgb -}} } +\definecolor{structframe}{RGB}{217, 217, 217} % structure-figure frame; matches the interactive view \arrayrulecolor{tableborder} \hypersetup{colorlinks=true, urlcolor=linkblue} @@ -194,6 +195,17 @@ Minimizer & {{ tex_software_name(analysis.software.minimizer) }} & {{ analysis.s % -------------------------------------------------------------------- \subsection{ {{- structure.id | tex -}} } +{% if tex.structure_figure_paths[structure.id] %} +\begin{figure}[H] +\centering +\begingroup +\setlength{\fboxsep}{0pt} +\setlength{\fboxrule}{0.4pt} +\fcolorbox{structframe}{white}{\makebox[\dimexpr\linewidth-2\fboxrule\relax][c]{\includegraphics[width=\dimexpr\linewidth-2\fboxrule\relax,height=0.5\textheight,keepaspectratio]{ {{- tex.structure_figure_paths[structure.id] -}} }}} +\endgroup +\end{figure} +{% endif %} + {% for category in structure.categories %} \subsubsection*{ {{- category.title | tex -}} } @@ -240,8 +252,6 @@ Minimizer & {{ tex_software_name(analysis.software.minimizer) }} & {{ analysis.s \subsection{ {{- experiment.id | tex -}} } {% if experiment.fit_data and tex.fit_figure_paths[experiment.id] %} -\subsubsection*{Fit quality} - \begin{figure}[H] \centering Measured and calculated fit for {{ experiment.id | tex }}. diff --git a/src/easydiffraction/report/tex_renderer.py b/src/easydiffraction/report/tex_renderer.py index 9346b5b5b..c30d6243c 100644 --- a/src/easydiffraction/report/tex_renderer.py +++ b/src/easydiffraction/report/tex_renderer.py @@ -124,6 +124,7 @@ def render_tex_report(context: dict[str, object]) -> str: context, fit_csv_paths=_fit_csv_paths(context), fit_figure_paths=_fit_figure_paths(context), + structure_figure_paths=_structure_figure_paths(context), ) return _environment().get_template(_TEMPLATE_NAME).render(**template_context) @@ -159,10 +160,12 @@ def save_tex_report( template_context = dict(context) template_context['report_style'] = report_style_context() fit_asset_paths = _write_fit_assets(project, context, tex_dir) + structure_figure_paths = _write_structure_assets(project, context, tex_dir) template_context['tex'] = _tex_context( context, fit_csv_paths=fit_asset_paths['csv'], fit_figure_paths=fit_asset_paths['figure'], + structure_figure_paths=structure_figure_paths, ) output_path.write_text( _render_prepared_context(template_context), @@ -264,11 +267,13 @@ def _tex_context( *, fit_csv_paths: dict[str, str], fit_figure_paths: dict[str, str], + structure_figure_paths: dict[str, str], ) -> dict[str, object]: """Return TeX-specific render context.""" return { 'fit_csv_paths': fit_csv_paths, 'fit_figure_paths': fit_figure_paths, + 'structure_figure_paths': structure_figure_paths, 'fit_bragg_tick_styles': fit_bragg_tick_styles(), 'fit_plot_ranges': _fit_plot_ranges(context), 'fit_plot_styles': fit_plot_styles(), @@ -414,6 +419,60 @@ def _fit_figure_paths(context: dict[str, object]) -> dict[str, str]: return paths +def _structure_asset_stem(struct_id: str) -> str: + """Return a filesystem-safe stem for a structure figure asset.""" + return f'struct_{_safe_asset_stem(struct_id)}' + + +def _structure_figure_paths(context: dict[str, object]) -> dict[str, str]: + """Return expected structure-figure PNG paths for TeX rendering.""" + paths: dict[str, str] = {} + for structure in context.get('structures') or []: + if not isinstance(structure, dict): + continue + struct_id = str(structure.get('id') or 'structure') + paths[struct_id] = f'data/{_structure_asset_stem(struct_id)}.png' + return paths + + +def _write_structure_assets( + project: object, + context: dict[str, object], + out_dir: pathlib.Path, +) -> dict[str, str]: + """Write one z-buffered PNG structure figure per structure.""" + from easydiffraction.display.structure.builder import build_scene # noqa: PLC0415 + from easydiffraction.display.structure.builder import ( # noqa: PLC0415 + structure_feature_availability, + ) + from easydiffraction.display.structure.renderers.raster import ( # noqa: PLC0415 + RasterStructureRenderer, + ) + + del context + structures = getattr(project, 'structures', None) + values = getattr(structures, 'values', None) + if not callable(values): + return {} + + renderer = RasterStructureRenderer() + window = project.structure_view.view_range() + style = project.structure_style + data_dir = out_dir / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + + figure_paths: dict[str, str] = {} + for structure in values(): + struct_id = str(getattr(structure, 'name', '') or 'structure') + availability = structure_feature_availability(structure, style=style) + features = project.display._resolve_structure_features('auto', availability) + scene = build_scene(structure, style=style, view_range=window, features=features) + figure_path = data_dir / f'{_structure_asset_stem(struct_id)}.png' + figure_path.write_bytes(renderer.render_png(scene, features=features)) + figure_paths[struct_id] = f'data/{figure_path.name}' + return figure_paths + + def _project_experiments_by_id(project: object) -> dict[str, object]: """Return project experiment objects keyed by datablock id.""" experiments = getattr(project, 'experiments', None) diff --git a/src/easydiffraction/utils/enums.py b/src/easydiffraction/utils/enums.py index 64d980177..44c3ac4dc 100644 --- a/src/easydiffraction/utils/enums.py +++ b/src/easydiffraction/utils/enums.py @@ -28,3 +28,15 @@ class VerbosityEnum(StrEnum): def default(cls) -> VerbosityEnum: """Return the default verbosity (FULL).""" return cls.FULL + + def description(self) -> str: + """ + Return a human-readable description of this verbosity level. + """ + if self is VerbosityEnum.FULL: + return 'Multi-line output with headers, tables, and details.' + if self is VerbosityEnum.SHORT: + return 'Single-line status messages per action.' + if self is VerbosityEnum.SILENT: + return 'No console output.' + return '' diff --git a/src/easydiffraction/utils/logging.py b/src/easydiffraction/utils/logging.py index a185205fb..1f6e4ddb8 100644 --- a/src/easydiffraction/utils/logging.py +++ b/src/easydiffraction/utils/logging.py @@ -41,6 +41,8 @@ from easydiffraction.utils.environment import in_pytest from easydiffraction.utils.environment import in_warp +CONSOLE_PARAGRAPH_STYLE = 'bold deep_sky_blue3' + # ====================================================================== # HANDLERS # ====================================================================== @@ -721,7 +723,7 @@ def paragraph(cls, title: str) -> None: if part.startswith("'") and part.endswith("'"): text.append(part) else: - text.append(part, style='bold deep_sky_blue3') + text.append(part, style=CONSOLE_PARAGRAPH_STYLE) formatted = f'{text.markup}' if not in_jupyter(): formatted = f'\n{formatted}' diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py index 31615f9d0..5155bf571 100644 --- a/src/easydiffraction/utils/utils.py +++ b/src/easydiffraction/utils/utils.py @@ -16,13 +16,16 @@ import pandas as pd import pooch from packaging.version import Version +from rich.markup import escape from uncertainties import UFloat from uncertainties import ufloat from uncertainties import ufloat_fromstr from easydiffraction.display.tables import TableRenderer from easydiffraction.io.ascii import extract_project_from_zip +from easydiffraction.utils.environment import in_jupyter from easydiffraction.utils.environment import resolve_artifact_path +from easydiffraction.utils.logging import CONSOLE_PARAGRAPH_STYLE from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log @@ -558,8 +561,11 @@ def list_tutorials() -> None: """ Display a table of available tutorial notebooks. - Shows tutorial ID, filename and title for all tutorials available - for the current version of easydiffraction. + In the terminal each row shows the tutorial ID, filename, and a + combined entry with the title on the first line and a dimmed + description on the second. In Jupyter the table shows the plain + title only, since the HTML backend cannot render the terminal + styling. """ index = _fetch_tutorials_index() if not index: @@ -569,20 +575,33 @@ def list_tutorials() -> None: version = _get_version_for_url() console.paragraph(f'Tutorials available for easydiffraction v{version}:') - columns_headers = ['id', 'file', 'title'] + columns_headers = ['id', 'file', 'tutorial'] columns_alignment = ['right', 'left', 'left'] columns_data = [] + use_markup = not in_jupyter() for tutorial_id in index: record = index[tutorial_id] filename = f'ed-{tutorial_id}.ipynb' title = record.get('title', '') - columns_data.append([tutorial_id, filename, title]) + description = record.get('description', '') + if not use_markup: + # Jupyter uses the HTML table backend, which would show Rich + # markup as literal text; keep the plain title there. + details = title + else: + styled_title = f'[{CONSOLE_PARAGRAPH_STYLE}]{escape(title)}[/]' + if description: + details = f'{styled_title}\n[dim]{escape(description)}[/dim]' + else: + details = styled_title + columns_data.append([tutorial_id, filename, details]) render_table( columns_headers=columns_headers, columns_data=columns_data, columns_alignment=columns_alignment, + width=shutil.get_terminal_size().columns, ) @@ -733,6 +752,7 @@ def render_table( columns_alignment: object, columns_headers: object = None, display_handle: object = None, + width: int | None = None, ) -> None: """ Render tabular data to the active display backend. @@ -749,6 +769,9 @@ def render_table( display_handle : object, default=None Optional display handle for in-place updates (e.g. in Jupyter or a terminal Live context). + width : int | None, default=None + Optional target table width. Honored by fixed-width backends + (Rich); ignored by reflowing ones (HTML). """ headers = [ (col, align) for col, align in zip(columns_headers, columns_alignment, strict=False) @@ -756,7 +779,7 @@ def render_table( df = pd.DataFrame(columns_data, columns=pd.MultiIndex.from_tuples(headers)) tabler = TableRenderer.get() - tabler.render(df, display_handle=display_handle) + tabler.render(df, display_handle=display_handle, width=width) def build_table_renderable( diff --git a/tests/integration/fitting/test_bayesian_helper_support.py b/tests/integration/fitting/test_bayesian_helper_support.py index c8028d3b7..8aac2e2ef 100644 --- a/tests/integration/fitting/test_bayesian_helper_support.py +++ b/tests/integration/fitting/test_bayesian_helper_support.py @@ -23,7 +23,15 @@ def __init__(self) -> None: class Param: - def __init__(self, unique_name: str, start: float, value: float, uncertainty: float) -> None: + def __init__( + self, + unique_name: str, + start: float, + value: float, + uncertainty: float, + *, + display_units: str | None = None, + ) -> None: self._identity = Identity() self._fit_start_value = start self.unique_name = unique_name @@ -31,6 +39,11 @@ def __init__(self, unique_name: str, start: float, value: float, uncertainty: fl self.value = value self.uncertainty = uncertainty self.units = 'arb' + self._display_units = display_units + + def resolve_display_units(self, context: str) -> str: + assert context == 'gui' + return self._display_units or self.units def test_posterior_samples_flatten(): @@ -353,7 +366,13 @@ def test_build_posterior_summary_row_restores_identifier_columns(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary from easydiffraction.analysis.fit_helpers.bayesian import _build_posterior_summary_row - parameter = Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05) + parameter = Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) summary = PosteriorParameterSummary( unique_name='a', display_name='a', @@ -373,7 +392,7 @@ def test_build_posterior_summary_row_restores_identifier_columns(): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.1500', '[1.0000, 1.3000]', '[red]1.107[/red]', @@ -394,7 +413,13 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): monkeypatch.setattr(bayesian, 'render_table', fake_render_table) bayesian._render_committed_parameter_table([ - Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05) + Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) ]) assert captured['columns_headers'] == [ @@ -425,7 +450,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.0000', '1.2000', '0.0500', @@ -448,7 +473,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): monkeypatch.setattr(bayesian, 'render_table', fake_render_table) bayesian._render_posterior_summary_table( - parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)], + parameters=[ + Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) + ], posterior_parameter_summaries=[ PosteriorParameterSummary( unique_name='a', @@ -492,7 +525,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.1500', '[1.0000, 1.3000]', '[red]1.107[/red]', @@ -612,7 +645,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): reporting.FitResults( success=True, - parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)], + parameters=[ + Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) + ], ).display_results() assert captured['columns_headers'] == [ @@ -643,7 +684,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.0000', '1.2000', '0.0500', diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py index da29f65ba..556d931ce 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_bayesian.py @@ -15,7 +15,15 @@ def __init__(self) -> None: class Param: - def __init__(self, unique_name: str, start: float, value: float, uncertainty: float) -> None: + def __init__( + self, + unique_name: str, + start: float, + value: float, + uncertainty: float, + *, + display_units: str | None = None, + ) -> None: self._identity = Identity() self._fit_start_value = start self.unique_name = unique_name @@ -23,6 +31,11 @@ def __init__(self, unique_name: str, start: float, value: float, uncertainty: fl self.value = value self.uncertainty = uncertainty self.units = 'arb' + self._display_units = display_units + + def resolve_display_units(self, context: str) -> str: + assert context == 'gui' + return self._display_units or self.units def test_module_import(): @@ -221,7 +234,13 @@ def test_build_posterior_summary_row_restores_identifier_columns(): from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary from easydiffraction.analysis.fit_helpers.bayesian import _build_posterior_summary_row - parameter = Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05) + parameter = Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) summary = PosteriorParameterSummary( unique_name='a', display_name='a', @@ -241,7 +260,7 @@ def test_build_posterior_summary_row_restores_identifier_columns(): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.1500', '[1.0000, 1.3000]', '[red]1.107[/red]', @@ -262,7 +281,13 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): monkeypatch.setattr(bayesian, 'render_table', fake_render_table) bayesian._render_committed_parameter_table([ - Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05) + Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) ]) assert captured['columns_headers'] == [ @@ -293,7 +318,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.0000', '1.2000', '0.0500', @@ -316,7 +341,15 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): monkeypatch.setattr(bayesian, 'render_table', fake_render_table) bayesian._render_posterior_summary_table( - parameters=[Param(unique_name='a', start=1.0, value=1.2, uncertainty=0.05)], + parameters=[ + Param( + unique_name='a', + start=1.0, + value=1.2, + uncertainty=0.05, + display_units='Ų', + ) + ], posterior_parameter_summaries=[ PosteriorParameterSummary( unique_name='a', @@ -360,7 +393,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.1500', '[1.0000, 1.3000]', '[red]1.107[/red]', diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py index d5a241d17..bba7ee45b 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py @@ -84,7 +84,11 @@ def __init__(self): self.value = 1.2 self.uncertainty = 0.05 self.name = 'a' - self.units = 'arb' + self.units = 'angstrom_squared' + + def resolve_display_units(self, context): + assert context == 'gui' + return 'Ų' from easydiffraction.analysis.fit_helpers import reporting @@ -127,7 +131,7 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): 'cat', 'entry', 'a', - 'arb', + 'Ų', '1.0000', '1.2000', '0.0500', diff --git a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py index 96667689b..af3072e34 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis_access_params.py +++ b/tests/unit/easydiffraction/analysis/test_analysis_access_params.py @@ -9,17 +9,25 @@ def _make_param( name, val, *, + units='none', + display_units=None, user_constrained=False, symmetry_constrained=False, ): + from easydiffraction.core.display_handler import DisplayHandler from easydiffraction.core.validation import AttributeSpec from easydiffraction.core.variable import Parameter from easydiffraction.io.cif.handler import CifHandler + display_handler = ( + DisplayHandler(display_units=display_units) if display_units is not None else None + ) param = Parameter( name=name, + units=units, value_spec=AttributeSpec(default=0.0), cif_handler=CifHandler(names=[f'_{cat}.{name}']), + display_handler=display_handler, ) param.value = val param._identity.datablock_entry_name = lambda: db @@ -295,3 +303,55 @@ def render(self, df): structure_df = rendered[0] assert structure_df['parameter', 'left'].tolist() == ['length_a'] + + +def test_free_params_uses_display_units_for_structures_and_experiments(monkeypatch): + import easydiffraction.analysis.analysis as analysis_mod + from easydiffraction.analysis.analysis import Analysis + + structure_param = _make_param( + 's1', + 'cell', + '', + 'length_a', + 4.0, + units='angstrom_squared', + display_units='Ų', + ) + experiment_param = _make_param( + 'e1', + 'time_of_flight', + '', + 'time_offset', + 12.0, + units='microseconds', + display_units='μs', + ) + + class Coll: + def __init__(self, params): + self.parameters = params + self.free_parameters = params + + def __iter__(self): + return iter(()) + + class Project: + def __init__(self): + self.structures = Coll([structure_param]) + self.experiments = Coll([experiment_param]) + + rendered = [] + + class FakeTableRenderer: + def render(self, df): + rendered.append(df) + + monkeypatch.setattr( + analysis_mod.TableRenderer, 'get', staticmethod(lambda: FakeTableRenderer()) + ) + Analysis(Project()).display.free_params() + + free_df = rendered[0] + assert free_df['parameter', 'left'].tolist() == ['length_a', 'time_offset'] + assert free_df['units', 'left'].tolist() == ['Ų', 'μs'] diff --git a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py index 1c52735fe..70c2f8e28 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py +++ b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py @@ -194,7 +194,11 @@ class FakeParam: unique_name = 'p1' value = 1.23 uncertainty = 0.01 - units = 'Å' + units = 'angstroms' + + def resolve_display_units(self, context): + assert context == 'gui' + return 'Å' class FakeResults: parameters = [FakeParam()] @@ -203,6 +207,7 @@ class FakeResults: assert 'expt1' in a._parameter_snapshots assert a._parameter_snapshots['expt1']['p1']['value'] == 1.23 assert a._parameter_snapshots['expt1']['p1']['uncertainty'] == 0.01 + assert a._parameter_snapshots['expt1']['p1']['units'] == 'Å' class TestBayesianProjection: @@ -261,7 +266,7 @@ def __getitem__(self, name): project = SimpleNamespace( experiments=Experiments(), structures=object(), - chart=SimpleNamespace(plotter=Plotter()), + rendering_plot=SimpleNamespace(plotter=Plotter()), _varname='proj', ) analysis = Analysis(project=project) diff --git a/tests/unit/easydiffraction/crystallography/test_crystallography.py b/tests/unit/easydiffraction/crystallography/test_crystallography.py index 73e1a1342..e8608f18d 100644 --- a/tests/unit/easydiffraction/crystallography/test_crystallography.py +++ b/tests/unit/easydiffraction/crystallography/test_crystallography.py @@ -8,3 +8,18 @@ def test_module_import(): expected_module_name = 'easydiffraction.crystallography.crystallography' actual_module_name = MUT.__name__ assert expected_module_name == actual_module_name + + +def test_symmetry_operators_falls_back_to_identity_for_unlisted_group(): + import numpy as np + + from easydiffraction.crystallography.crystallography import symmetry_operators + + # P 1 is absent from the local SPACE_GROUPS table (the default-structure + # case that previously raised while building a structure scene). + ops = symmetry_operators('P 1', '') + + assert len(ops) == 1 + rotation, translation = ops[0] + assert np.array_equal(rotation, np.eye(3, dtype=int)) + assert np.array_equal(translation, np.zeros(3)) diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py index 92b399504..272781845 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py @@ -145,8 +145,11 @@ def test_show_peak_profile_types(self, capsys): out = capsys.readouterr().out assert len(out) > 0 - def test_show_peak_profile_types_includes_current(self, capsys): + def test_show_peak_profile_types_uses_context_aliases(self, capsys): ex = ConcretePd(name='pd1', type=_mk_type_powder_cwl_bragg()) ex.peak.show_supported() out = capsys.readouterr().out - assert str(ex.peak.type) in out + assert 'Alias' not in out + assert 'cwl-pseudo-voigt' not in out + assert 'pseudo-voigt' in out + assert 'pseudo-voigt + empirical asymmetry' in out diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py new file mode 100644 index 000000000..e8e514cce --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_default.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the geom category default module (Geom).""" + +from __future__ import annotations + +import pytest + +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.datablocks.structure.categories.geom.default import Geom +from easydiffraction.utils.logging import Logger + + +def test_module_import(): + import easydiffraction.datablocks.structure.categories.geom.default as MUT + + expected_module_name = 'easydiffraction.datablocks.structure.categories.geom.default' + assert MUT.__name__ == expected_module_name + + +# ---------------------------------------------------------------------- +# Class-level metadata +# ---------------------------------------------------------------------- + + +class TestGeomClass: + def test_is_category_item_subclass(self): + assert issubclass(Geom, CategoryItem) + + def test_category_code(self): + assert Geom._category_code == 'geom' + + def test_type_info_is_type_info(self): + assert isinstance(Geom.type_info, TypeInfo) + + def test_type_info_tag(self): + assert Geom.type_info.tag == 'default' + + def test_type_info_description(self): + assert Geom.type_info.description == 'Structure bond-geometry cutoffs' + + +# ---------------------------------------------------------------------- +# Construction and defaults +# ---------------------------------------------------------------------- + + +class TestGeomConstruction: + def test_instantiation(self): + geom = Geom() + assert geom is not None + + def test_instantiation_returns_fresh_instances(self): + first = Geom() + second = Geom() + assert first is not second + + def test_identity_category_code(self): + geom = Geom() + assert geom._identity.category_code == 'geom' + + def test_min_bond_distance_cutoff_is_numeric_descriptor(self): + geom = Geom() + assert isinstance(geom.min_bond_distance_cutoff, NumericDescriptor) + + def test_bond_distance_incr_is_numeric_descriptor(self): + geom = Geom() + assert isinstance(geom.bond_distance_incr, NumericDescriptor) + + def test_default_min_bond_distance_cutoff(self): + geom = Geom() + assert geom.min_bond_distance_cutoff.value == 0.0 + + def test_default_bond_distance_incr(self): + geom = Geom() + assert geom.bond_distance_incr.value == 0.25 + + def test_descriptor_names(self): + geom = Geom() + assert geom.min_bond_distance_cutoff.name == 'min_bond_distance_cutoff' + assert geom.bond_distance_incr.name == 'bond_distance_incr' + + def test_descriptor_descriptions(self): + geom = Geom() + assert geom.min_bond_distance_cutoff.description == ( + 'Minimum permitted bonded distance (angstrom).' + ) + assert geom.bond_distance_incr.description == ( + 'Increment added to the summed bonding radii (angstrom).' + ) + + def test_parameters_lists_both_descriptors(self): + geom = Geom() + names = {p.name for p in geom.parameters} + assert names == {'min_bond_distance_cutoff', 'bond_distance_incr'} + + +# ---------------------------------------------------------------------- +# CIF handler names (cif_core ``_geom``) +# ---------------------------------------------------------------------- + + +class TestGeomCifHandlers: + def test_min_bond_distance_cutoff_cif_name(self): + geom = Geom() + assert geom.min_bond_distance_cutoff._cif_handler.names == [ + '_geom.min_bond_distance_cutoff', + ] + + def test_bond_distance_incr_cif_name(self): + geom = Geom() + assert geom.bond_distance_incr._cif_handler.names == [ + '_geom.bond_distance_incr', + ] + + +# ---------------------------------------------------------------------- +# Setters — valid values +# ---------------------------------------------------------------------- + + +class TestGeomSettersValid: + def test_set_min_bond_distance_cutoff(self): + geom = Geom() + geom.min_bond_distance_cutoff = 0.5 + assert geom.min_bond_distance_cutoff.value == 0.5 + + def test_set_bond_distance_incr(self): + geom = Geom() + geom.bond_distance_incr = 0.4 + assert geom.bond_distance_incr.value == 0.4 + + def test_set_min_bond_distance_cutoff_to_zero_boundary(self): + # The validator allows ge=0.0, so the lower boundary is valid. + geom = Geom() + geom.min_bond_distance_cutoff = 1.0 + geom.min_bond_distance_cutoff = 0.0 + assert geom.min_bond_distance_cutoff.value == 0.0 + + def test_set_bond_distance_incr_to_zero_boundary(self): + geom = Geom() + geom.bond_distance_incr = 0.0 + assert geom.bond_distance_incr.value == 0.0 + + def test_set_min_bond_distance_cutoff_accepts_int(self): + geom = Geom() + geom.min_bond_distance_cutoff = 2 + assert geom.min_bond_distance_cutoff.value == 2 + + +# ---------------------------------------------------------------------- +# Setters — invalid values +# +# RangeValidator routes out-of-range values through +# ``Diagnostics.range_mismatch`` -> ``log.error(..., exc_type=TypeError)``. +# In RAISE mode this raises ``TypeError``; in WARN mode the current +# value is kept. Tests pin the Logger reaction with monkeypatch so they +# do not depend on global logger state leaked by other tests. +# ---------------------------------------------------------------------- + + +class TestGeomSettersInvalid: + def test_negative_min_bond_distance_cutoff_raises_in_raise_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + geom = Geom() + with pytest.raises(TypeError): + geom.min_bond_distance_cutoff = -1.0 + + def test_negative_bond_distance_incr_raises_in_raise_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + geom = Geom() + with pytest.raises(TypeError): + geom.bond_distance_incr = -0.5 + + def test_negative_min_bond_distance_cutoff_kept_in_warn_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + geom = Geom() + geom.min_bond_distance_cutoff = -1.0 + # The out-of-range write is rejected; the default is retained. + assert geom.min_bond_distance_cutoff.value == 0.0 + + def test_negative_bond_distance_incr_kept_in_warn_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + geom = Geom() + geom.bond_distance_incr = -0.5 + assert geom.bond_distance_incr.value == 0.25 + + def test_wrong_type_min_bond_distance_cutoff_kept_in_warn_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + geom = Geom() + geom.min_bond_distance_cutoff = 'not-a-number' + assert geom.min_bond_distance_cutoff.value == 0.0 + + def test_wrong_type_bond_distance_incr_raises_in_raise_mode(self, monkeypatch): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + geom = Geom() + with pytest.raises(TypeError): + geom.bond_distance_incr = 'not-a-number' + + +# ---------------------------------------------------------------------- +# CIF serialisation and round-trip +# ---------------------------------------------------------------------- + + +class TestGeomAsCif: + def test_as_cif_is_string(self): + geom = Geom() + assert isinstance(geom.as_cif, str) + + def test_as_cif_contains_both_tags(self): + geom = Geom() + cif = geom.as_cif + assert '_geom.min_bond_distance_cutoff' in cif + assert '_geom.bond_distance_incr' in cif + + def test_as_cif_default_lines(self): + geom = Geom() + lines = geom.as_cif.splitlines() + assert lines == [ + '_geom.min_bond_distance_cutoff 0.', + '_geom.bond_distance_incr 0.25', + ] + + def test_as_cif_reflects_updated_values(self): + geom = Geom() + geom.min_bond_distance_cutoff = 0.8 + geom.bond_distance_incr = 0.3 + cif = geom.as_cif + assert '_geom.min_bond_distance_cutoff 0.8' in cif + assert '_geom.bond_distance_incr 0.3' in cif + + def test_from_cif_round_trip(self): + import gemmi + + # All values are in range, so no validation error is triggered; + # the round-trip is independent of the global Logger reaction. + source = Geom() + source.min_bond_distance_cutoff = 0.6 + source.bond_distance_incr = 0.45 + + block = gemmi.cif.read_string(f'data_test\n\n{source.as_cif}\n').sole_block() + + restored = Geom() + restored.from_cif(block) + + assert restored.min_bond_distance_cutoff.value == 0.6 + assert restored.bond_distance_incr.value == 0.45 diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py new file mode 100644 index 000000000..f023c2d7e --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/structure/categories/geom/test_factory.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the geom category factory.""" + +from __future__ import annotations + +import pytest + +from easydiffraction.core.factory import FactoryBase +from easydiffraction.datablocks.structure.categories.geom.default import Geom +from easydiffraction.datablocks.structure.categories.geom.factory import GeomFactory + + +def test_module_import(): + import easydiffraction.datablocks.structure.categories.geom.factory as MUT + + expected_module_name = 'easydiffraction.datablocks.structure.categories.geom.factory' + assert MUT.__name__ == expected_module_name + + +class TestGeomFactoryClass: + def test_is_factory_base_subclass(self): + assert issubclass(GeomFactory, FactoryBase) + + def test_default_rules_universal_fallback(self): + # The only rule is the universal (empty-condition) fallback. + assert GeomFactory._default_rules == {frozenset(): 'default'} + + def test_registry_is_independent_from_base(self): + # __init_subclass__ gives each subclass its own registry list. + assert GeomFactory._registry is not FactoryBase._registry + + def test_geom_is_registered(self): + assert Geom in GeomFactory._registry + + +class TestSupportedTags: + def test_supported_tags_returns_list(self): + tags = GeomFactory.supported_tags() + assert isinstance(tags, list) + + def test_default_tag_is_supported(self): + assert 'default' in GeomFactory.supported_tags() + + def test_supported_map_links_tag_to_class(self): + supported = GeomFactory._supported_map() + assert supported['default'] is Geom + + +class TestDefaultTag: + def test_default_tag_no_conditions(self): + assert GeomFactory.default_tag() == 'default' + + def test_default_tag_ignores_extra_conditions(self): + # The universal fallback still wins for unrelated conditions. + assert GeomFactory.default_tag(scattering_type='bragg') == 'default' + + +class TestCreate: + def test_create_default_returns_geom(self): + obj = GeomFactory.create('default') + assert isinstance(obj, Geom) + + def test_create_returns_fresh_instances(self): + first = GeomFactory.create('default') + second = GeomFactory.create('default') + assert first is not second + + def test_create_unknown_tag_raises_value_error(self): + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + GeomFactory.create('missing') + + def test_create_default_has_expected_defaults(self): + geom = GeomFactory.create('default') + assert geom.min_bond_distance_cutoff.value == 0.0 + assert geom.bond_distance_incr.value == 0.25 + + +class TestCreateDefaultFor: + def test_create_default_for_no_conditions(self): + obj = GeomFactory.create_default_for() + assert isinstance(obj, Geom) + + +class TestSupportedFor: + def test_supported_for_no_filters_includes_geom(self): + result = GeomFactory.supported_for() + assert Geom in result + + def test_supported_for_calculator_filter_does_not_exclude(self): + # Geom declares no calculator_support, so the filter cannot + # exclude it. + result = GeomFactory.supported_for(calculator='cryspy') + assert Geom in result + + def test_supported_for_sample_form_filter_does_not_exclude(self): + # Geom declares no compatibility, so axis filters cannot + # exclude it. + result = GeomFactory.supported_for(sample_form='powder') + assert Geom in result + + +class TestShowSupported: + def test_show_supported_prints_table(self, capsys): + GeomFactory.show_supported() + out = capsys.readouterr().out + assert 'Supported types' in out + + def test_show_supported_lists_default_tag(self, capsys): + GeomFactory.show_supported() + out = capsys.readouterr().out + assert 'default' in out diff --git a/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py index 6d8967703..f7f968f01 100644 --- a/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py +++ b/tests/unit/easydiffraction/datablocks/structure/item/test_base_coverage.py @@ -93,11 +93,6 @@ def test_atom_sites_setter_replaces_instance(self, structure): class TestStructureDisplay: - def test_show(self, structure, capsys): - structure.show() - out = capsys.readouterr().out - assert 'test_struct' in out - def test_show_as_cif(self, structure, capsys): structure.show_as_cif() out = capsys.readouterr().out diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index 809cbc772..16dea0113 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -98,6 +98,23 @@ def test_ascii_plotter_plot_single_crystal(capsys): assert '·' in out +def test_ascii_plotter_single_crystal_marker_uses_paragraph_style(): + from easydiffraction.display.plotters.ascii import AsciiPlotter + from easydiffraction.display.plotters.ascii import SINGLE_CRYSTAL_SCATTER_SYMBOL + from easydiffraction.utils.logging import CONSOLE_PARAGRAPH_STYLE + + line = AsciiPlotter._single_crystal_grid_line([ + ' ', + SINGLE_CRYSTAL_SCATTER_SYMBOL, + '·', + ]) + + marker_start = line.plain.index(SINGLE_CRYSTAL_SCATTER_SYMBOL) + assert line.spans[0].start == marker_start + assert line.spans[0].end == marker_start + 1 + assert line.spans[0].style == CONSOLE_PARAGRAPH_STYLE + + def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row(capsys): from easydiffraction.display.plotters.ascii import AsciiPlotter from easydiffraction.display.plotters.base import BraggTickSet diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 35fd5f2b8..812ec9157 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -13,14 +13,78 @@ def test_module_import(): assert expected_module_name == actual_module_name -def test_get_layout_sets_title_and_axis_title_font_sizes(): +@pytest.mark.parametrize( + 'is_dark_mode', + [False, True], +) +def test_get_layout_sets_title_axis_and_theme_colors( + monkeypatch, + is_dark_mode, +): import easydiffraction.display.plotters.plotly as pp + if is_dark_mode: + background_color = pp.DARK_BACKGROUND_COLOR + axis_color = pp.DARK_AXIS_FRAME_COLOR + grid_color = pp.DARK_INNER_TICK_GRID_COLOR + else: + background_color = pp.LIGHT_BACKGROUND_COLOR + axis_color = pp.LIGHT_AXIS_FRAME_COLOR + grid_color = pp.LIGHT_INNER_TICK_GRID_COLOR + + monkeypatch.setattr( + pp.PlotlyPlotter, + '_is_dark_mode', + classmethod(lambda cls: is_dark_mode), + ) + layout = pp.PlotlyPlotter._get_layout('Title', ['x axis', 'y axis']) assert layout.title.font.size == pp.TITLE_FONT_SIZE assert layout.xaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE assert layout.yaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE + assert layout.paper_bgcolor == background_color + assert layout.plot_bgcolor == background_color + assert layout.xaxis.linecolor == axis_color + assert layout.yaxis.linecolor == axis_color + assert layout.xaxis.gridcolor == grid_color + assert layout.yaxis.gridcolor == grid_color + assert layout.xaxis.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF + assert layout.yaxis.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF + + +@pytest.mark.parametrize( + ('is_dark_mode', 'background_color'), + [ + (False, 'light-background'), + (True, 'dark-background'), + ], +) +def test_correlation_colorscale_uses_theme_background( + monkeypatch, + is_dark_mode, + background_color, +): + import easydiffraction.display.plotters.plotly as pp + + monkeypatch.setattr( + pp.PlotlyPlotter, + '_is_dark_mode', + classmethod(lambda cls: is_dark_mode), + ) + monkeypatch.setattr( + pp.PlotlyPlotter, + '_background_color', + classmethod(lambda cls: background_color), + ) + + colorscale = pp.PlotlyPlotter._correlation_colorscale() + + assert colorscale == [ + (0.0, '#d73027'), + (0.5, background_color), + (1.0, '#4575b4'), + ] def test_get_trace_and_plot(monkeypatch): @@ -166,6 +230,34 @@ def __init__(self, html): assert captured.get('show_called') is not True assert captured['config']['displayModeBar'] is True assert captured['config']['displaylogo'] is False + assert captured['config']['responsive'] is True + assert 'data-jp-theme-light' in captured['post_script'] + assert 'data-md-color-scheme' in captured['post_script'] + assert 'graphDiv.dataset.edPlotlyTheme' in captured['post_script'] + assert f"background: '{pp.DARK_BACKGROUND_COLOR}'" in captured['post_script'] + assert f"background: '{pp.LIGHT_BACKGROUND_COLOR}'" in captured['post_script'] + assert f"axisFrame: '{pp.DARK_AXIS_FRAME_COLOR}'" in captured['post_script'] + assert f"axisFrame: '{pp.LIGHT_AXIS_FRAME_COLOR}'" in captured['post_script'] + assert f"innerTickGrid: '{pp.DARK_INNER_TICK_GRID_COLOR}'" in captured['post_script'] + assert f"innerTickGrid: '{pp.LIGHT_INNER_TICK_GRID_COLOR}'" in captured['post_script'] + assert f"hoverBackground: '{pp.DARK_HOVER_BACKGROUND_COLOR}'" in captured['post_script'] + assert f"legend: '{pp.DARK_LEGEND_BACKGROUND_COLOR}'" in captured['post_script'] + assert 'ed-plotly-modebar-theme-style' in captured['post_script'] + assert 'ed-plotly-themed-modebar' in captured['post_script'] + assert '--ed-plotly-modebar-icon-color' in captured['post_script'] + assert '--ed-plotly-modebar-icon-hover-opacity' in captured['post_script'] + assert 'const correlationColorscale = function (colors) {' in captured['post_script'] + assert 'const themeSync = meta.ed_plotly_theme_sync;' in captured['post_script'] + assert 'const applyAnnotationTheme = function (update, colors) {' in captured['post_script'] + assert 'const shapeIndexes = themeSync.axis_frame_shape_indexes;' in captured['post_script'] + assert 'if (themeSync.correlation_heatmap !== true) {' in captured['post_script'] + assert 'window.Plotly.restyle(' in captured['post_script'] + assert 'window.Plotly.relayout(graphDiv, update)' in captured['post_script'] + assert 'Promise.all(pending).then(function () {' in captured['post_script'] + assert 'window.Plotly.Plots.resize(graphDiv)' in captured['post_script'] + assert "document.addEventListener('visibilitychange'" in captured['post_script'] + assert "window.addEventListener('focus', scheduleResize);" in captured['post_script'] + assert 'new ResizeObserver(scheduleResize)' in captured['post_script'] assert 'data-legend-toggle="true"' in captured['post_script'] assert 'Toggle legend' in captured['post_script'] assert 'graphDiv.dataset.legendVisible' in captured['post_script'] @@ -229,7 +321,9 @@ def __init__(self, html): plotter._show_figure(DummyFig()) assert captured.get('show_called') is not True - assert captured['post_script'] is None + assert captured['post_script'] is not None + assert 'data-jp-theme-light' in captured['post_script'] + assert 'data-legend-toggle="true"' not in captured['post_script'] assert captured['displayed_html'] == '
plot
' @@ -278,7 +372,8 @@ def __init__(self, html): plotter._show_figure(DummyFig()) assert captured.get('show_called') is not True - assert captured['post_script'] is None + assert captured['post_script'] is not None + assert 'data-jp-theme-light' in captured['post_script'] assert 'aspect-ratio: 1 / 1;' in captured['displayed_html'] assert 'ed-fixed-aspect-plotly-wrapper' in captured['displayed_html'] assert '
plot
' in captured['displayed_html'] @@ -350,12 +445,20 @@ def __init__(self, html): assert trace.kwargs['y'] == y_meas assert trace.kwargs['mode'] == 'markers' assert 'error_y' in trace.kwargs + assert trace.kwargs['marker']['size'] == pp.MEASURED_MARKER_SIZE + assert trace.kwargs['marker']['line']['color'] == pp.DEFAULT_COLORS['meas'] + assert trace.kwargs['error_y']['thickness'] == pp.MEASURED_ERROR_BAR_THICKNESS + assert trace.kwargs['error_y']['width'] == pp.MEASURED_ERROR_BAR_WIDTH - # Exercise _get_diagonal_shape - shape = plotter._get_diagonal_shape() + # Exercise _get_diagonal_shape (now a data-coordinate y=x line) + shape = plotter._get_diagonal_shape(0.0, 10.0) assert shape['type'] == 'line' - assert shape['xref'] == 'paper' - assert shape['yref'] == 'paper' + assert shape['xref'] == 'x' + assert shape['yref'] == 'y' + assert (shape['x0'], shape['y0']) == (0.0, 0.0) + assert (shape['x1'], shape['y1']) == (10.0, 10.0) + assert shape['line']['color'] == pp.DIAGONAL_LINE_COLOR + assert shape['line']['width'] == pp.DIAGONAL_LINE_WIDTH # Exercise plot_single_crystal plotter.plot_single_crystal( @@ -370,6 +473,44 @@ def __init__(self, html): assert dummy_display_calls['count'] == 1 or shown['count'] == 1 +def test_single_crystal_axis_range_unions_calc_and_meas_with_uncertainty(): + import easydiffraction.display.plotters.plotly as pp + + minimum, maximum = pp.single_crystal_axis_range( + x_calc=[2.0, 8.0], + y_meas=[1.0, 10.0], + y_meas_su=[0.5, 1.0], + ) + # Spans meas-su minimum (0.5) to meas+su maximum (11.0); calc 2..8 is + # inside. A 5% margin of the 10.5 span pads both ends symmetrically. + assert minimum == pytest.approx(0.5 - 0.525) + assert maximum == pytest.approx(11.0 + 0.525) + + +def test_single_crystal_axis_range_handles_missing_uncertainty(): + import easydiffraction.display.plotters.plotly as pp + + minimum, maximum = pp.single_crystal_axis_range( + x_calc=[0.0, 4.0], + y_meas=[1.0, 3.0], + y_meas_su=None, + ) + assert minimum < 0.0 + assert maximum > 4.0 + + +def test_single_crystal_tick_step_rounds_to_nice_value(): + import easydiffraction.display.plotters.plotly as pp + + # Span 3000 over ~6 intervals -> raw 500 -> nice 500. + assert pp.single_crystal_tick_step(0.0, 3000.0) == pytest.approx(500.0) + # Padded heidi-like range (span just above the 500 threshold) rounds to + # 500, not 750 (regression for the old round-up logic). + assert pp.single_crystal_tick_step(-158.275, 2841.73) == pytest.approx(500.0) + # Degenerate span falls back to 1.0. + assert pp.single_crystal_tick_step(5.0, 5.0) == pytest.approx(1.0) + + def test_get_bragg_tick_trace_includes_peak_metadata(): from easydiffraction.display.plotters.base import BraggTickSet from easydiffraction.display.plotters.plotly import PlotlyPlotter @@ -452,6 +593,12 @@ def fake_show_figure(self, fig): assert fig.layout.xaxis.matches == 'x' assert fig.layout.xaxis2.matches == 'x' assert fig.layout.xaxis3.matches == 'x' + assert fig.layout.xaxis.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF + assert fig.layout.xaxis2.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF + assert fig.layout.xaxis3.ticklabelstandoff == pp.X_AXIS_TICK_LABEL_STANDOFF + assert fig.layout.yaxis.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF + assert fig.layout.yaxis2.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF + assert fig.layout.yaxis3.ticklabelstandoff == pp.Y_AXIS_TICK_LABEL_STANDOFF assert fig.layout.yaxis3.scaleanchor == 'y' assert fig.layout.yaxis3.scaleratio == pytest.approx(1.0) diff --git a/tests/unit/easydiffraction/display/structure/assets/test_colors.py b/tests/unit/easydiffraction/display/structure/assets/test_colors.py new file mode 100644 index 000000000..2d723716b --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/assets/test_colors.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the structure-view colour palette module.""" + +from __future__ import annotations + +import pytest + +from easydiffraction.display.structure.assets import colors +from easydiffraction.display.structure.assets.colors import AXIS_COLORS +from easydiffraction.display.structure.assets.colors import DARK_THEME +from easydiffraction.display.structure.assets.colors import DEFAULT_COLOR +from easydiffraction.display.structure.assets.colors import LIGHT_THEME +from easydiffraction.display.structure.assets.colors import VACANCY_COLOR +from easydiffraction.display.structure.assets.colors import color_for +from easydiffraction.display.structure.assets.colors import theme_colors +from easydiffraction.display.structure.assets.elements import ELEMENT_COLORS + + +def _is_rgb(value): + """Return True when value is an in-range 0-255 RGB triple.""" + return ( + isinstance(value, tuple) + and len(value) == 3 + and all(isinstance(channel, int) for channel in value) + and all(0 <= channel <= 255 for channel in value) + ) + + +# ------------------------------------------------------------------ +# Module surface +# ------------------------------------------------------------------ + + +class TestModule: + def test_module_import(self): + expected_module_name = 'easydiffraction.display.structure.assets.colors' + assert colors.__name__ == expected_module_name + + def test_public_callables_present(self): + assert callable(colors.color_for) + assert callable(colors.theme_colors) + + +# ------------------------------------------------------------------ +# Module-level constants +# ------------------------------------------------------------------ + + +class TestConstants: + def test_default_color_value(self): + assert DEFAULT_COLOR == (255, 192, 203) + + def test_default_color_is_rgb(self): + assert _is_rgb(DEFAULT_COLOR) + + def test_vacancy_color_value(self): + assert VACANCY_COLOR == (210, 210, 210) + + def test_vacancy_color_is_rgb(self): + assert _is_rgb(VACANCY_COLOR) + + def test_axis_colors_keys(self): + assert set(AXIS_COLORS) == {'a', 'b', 'c'} + + def test_axis_colors_values(self): + assert AXIS_COLORS['a'] == (220, 40, 40) + assert AXIS_COLORS['b'] == (40, 180, 40) + assert AXIS_COLORS['c'] == (40, 80, 220) + + def test_axis_colors_all_rgb(self): + assert all(_is_rgb(value) for value in AXIS_COLORS.values()) + + def test_axis_colors_red_green_blue_dominance(self): + # a=red, b=green, c=blue (VESTA convention): the named channel + # is the strongest component of each axis colour. + assert AXIS_COLORS['a'][0] == max(AXIS_COLORS['a']) + assert AXIS_COLORS['b'][1] == max(AXIS_COLORS['b']) + assert AXIS_COLORS['c'][2] == max(AXIS_COLORS['c']) + + def test_light_theme_keys(self): + assert set(LIGHT_THEME) == {'background', 'foreground'} + + def test_dark_theme_keys(self): + assert set(DARK_THEME) == {'background', 'foreground'} + + def test_light_theme_values(self): + assert LIGHT_THEME['background'] == (255, 255, 255) + assert LIGHT_THEME['foreground'] == (33, 33, 33) + + def test_dark_theme_values(self): + assert DARK_THEME['background'] == (33, 33, 33) + assert DARK_THEME['foreground'] == (235, 235, 235) + + def test_theme_values_all_rgb(self): + for theme in (LIGHT_THEME, DARK_THEME): + assert all(_is_rgb(value) for value in theme.values()) + + def test_light_and_dark_backgrounds_differ(self): + assert LIGHT_THEME['background'] != DARK_THEME['background'] + + def test_rgb_type_alias(self): + assert colors.Rgb == tuple[int, int, int] + + +# ------------------------------------------------------------------ +# color_for +# ------------------------------------------------------------------ + + +class TestColorFor: + def test_known_element_known_scheme_returns_scheme_color(self): + # Si has distinct jmol and vesta entries; requesting vesta must + # return the vesta value, not the jmol fallback. + assert color_for('Si', 'vesta') == (27, 59, 250) + + def test_jmol_scheme_returns_jmol_color(self): + assert color_for('Si', 'jmol') == (240, 200, 160) + + def test_scheme_colors_can_differ(self): + assert color_for('Si', 'jmol') != color_for('Si', 'vesta') + + def test_returns_exact_palette_reference(self): + # The function returns the stored tuple, not a copy or recolour. + assert color_for('Fe', 'vesta') == ELEMENT_COLORS['Fe']['vesta'] + assert color_for('Fe', 'jmol') == ELEMENT_COLORS['Fe']['jmol'] + + def test_unknown_scheme_falls_back_to_jmol(self): + # 'cpk' is not a stored scheme key, so the element's jmol colour + # is used as the fallback. + assert color_for('Fe', 'cpk') == ELEMENT_COLORS['Fe']['jmol'] + + def test_none_scheme_value_falls_back_to_jmol(self): + # Some heavy elements store vesta=None; requesting vesta must + # fall through to the jmol colour rather than return None. + element = next(el for el, data in ELEMENT_COLORS.items() if data.get('vesta') is None) + result = color_for(element, 'vesta') + assert result == ELEMENT_COLORS[element]['jmol'] + assert result is not None + + def test_unknown_element_returns_default(self): + assert color_for('Xx', 'jmol') == DEFAULT_COLOR + + def test_unknown_element_returns_default_for_any_scheme(self): + assert color_for('Zz', 'vesta') == DEFAULT_COLOR + + def test_empty_element_returns_default(self): + assert color_for('', 'jmol') == DEFAULT_COLOR + + def test_case_sensitive_lookup(self): + # Symbols are stored title-cased; a lower-cased symbol is unknown. + assert color_for('fe', 'jmol') == DEFAULT_COLOR + + @pytest.mark.parametrize('scheme', ['jmol', 'vesta']) + def test_all_elements_return_rgb(self, scheme): + for element in ELEMENT_COLORS: + assert _is_rgb(color_for(element, scheme)) + + def test_never_returns_none(self): + # Covers the `entry.get('jmol') or DEFAULT_COLOR` guard for every + # element under both schemes. + for element in ELEMENT_COLORS: + assert color_for(element, 'vesta') is not None + assert color_for(element, 'jmol') is not None + + +# ------------------------------------------------------------------ +# theme_colors +# ------------------------------------------------------------------ + + +class TestThemeColors: + def test_dark_returns_dark_theme(self): + assert theme_colors(dark=True) == DARK_THEME + + def test_light_returns_light_theme(self): + assert theme_colors(dark=False) == LIGHT_THEME + + def test_dark_returns_module_dict_reference(self): + assert theme_colors(dark=True) is DARK_THEME + + def test_light_returns_module_dict_reference(self): + assert theme_colors(dark=False) is LIGHT_THEME + + def test_result_has_background_and_foreground(self): + result = theme_colors(dark=False) + assert set(result) == {'background', 'foreground'} + + def test_dark_is_keyword_only(self): + # `dark` is a keyword-only parameter; passing it positionally + # is a TypeError. + dark_flag = True + with pytest.raises(TypeError): + theme_colors(dark_flag) diff --git a/tests/unit/easydiffraction/display/structure/assets/test_elements.py b/tests/unit/easydiffraction/display/structure/assets/test_elements.py new file mode 100644 index 000000000..559be523e --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/assets/test_elements.py @@ -0,0 +1,221 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the bundled per-element radii and colour palettes.""" + +from __future__ import annotations + +import easydiffraction.display.structure.assets.elements as elements_module +from easydiffraction.display.structure.assets.elements import ELEMENT_COLORS +from easydiffraction.display.structure.assets.elements import ELEMENT_RADII + +# Model/scheme keys every entry must carry, matching the consumers in +# radii.py (radius_for) and colors.py (color_for). +RADIUS_MODELS = ('vdw', 'covalent', 'ionic', 'atomic') +COLOR_SCHEMES = ('jmol', 'vesta') + + +def test_module_import(): + expected_module_name = 'easydiffraction.display.structure.assets.elements' + actual_module_name = elements_module.__name__ + assert expected_module_name == actual_module_name + + +# ------------------------------------------------------------------ +# ELEMENT_RADII — container shape +# ------------------------------------------------------------------ + + +class TestElementRadiiContainer: + def test_is_dict(self): + assert isinstance(ELEMENT_RADII, dict) + + def test_not_empty(self): + assert len(ELEMENT_RADII) > 0 + + def test_covers_full_periodic_table(self): + # Hydrogen through oganesson: 118 elements. + assert len(ELEMENT_RADII) == 118 + + def test_first_and_last_symbols(self): + symbols = list(ELEMENT_RADII) + assert symbols[0] == 'H' + assert symbols[-1] == 'Og' + + def test_known_symbols_present(self): + for symbol in ('H', 'C', 'N', 'O', 'Fe', 'Si', 'U', 'Og'): + assert symbol in ELEMENT_RADII + + def test_keys_are_titlecase_element_symbols(self): + for symbol in ELEMENT_RADII: + assert isinstance(symbol, str) + assert 1 <= len(symbol) <= 2 + assert symbol[0].isupper() + assert symbol.istitle() + + +# ------------------------------------------------------------------ +# ELEMENT_RADII — per-entry contract +# ------------------------------------------------------------------ + + +class TestElementRadiiEntries: + def test_every_entry_is_a_dict(self): + for entry in ELEMENT_RADII.values(): + assert isinstance(entry, dict) + + def test_every_entry_has_exactly_the_model_keys(self): + expected_keys = set(RADIUS_MODELS) + for symbol, entry in ELEMENT_RADII.items(): + assert set(entry) == expected_keys, symbol + + def test_values_are_float_or_none(self): + for symbol, entry in ELEMENT_RADII.items(): + for model in RADIUS_MODELS: + value = entry[model] + assert value is None or isinstance(value, (int, float)), (symbol, model) + + def test_non_none_values_are_positive(self): + for symbol, entry in ELEMENT_RADII.items(): + for model in RADIUS_MODELS: + value = entry[model] + if value is not None: + assert value > 0.0, (symbol, model) + + def test_covalent_radius_always_present(self): + # radius_for() relies on covalent as the universal fallback, so + # no bundled element may have a None covalent radius. + for symbol, entry in ELEMENT_RADII.items(): + assert entry['covalent'] is not None, symbol + assert entry['covalent'] > 0.0, symbol + + +# ------------------------------------------------------------------ +# ELEMENT_RADII — spot-checked known values +# ------------------------------------------------------------------ + + +class TestElementRadiiKnownValues: + def test_hydrogen(self): + assert ELEMENT_RADII['H'] == { + 'vdw': 1.1, + 'covalent': 0.31, + 'ionic': None, + 'atomic': 0.25, + } + + def test_iron(self): + assert ELEMENT_RADII['Fe'] == { + 'vdw': 1.72, + 'covalent': 1.32, + 'ionic': 0.78, + 'atomic': 1.4, + } + + def test_helium_has_no_ionic_or_atomic(self): + assert ELEMENT_RADII['He']['ionic'] is None + assert ELEMENT_RADII['He']['atomic'] is None + assert ELEMENT_RADII['He']['covalent'] == 0.28 + + +# ------------------------------------------------------------------ +# ELEMENT_COLORS — container shape +# ------------------------------------------------------------------ + + +class TestElementColorsContainer: + def test_is_dict(self): + assert isinstance(ELEMENT_COLORS, dict) + + def test_not_empty(self): + assert len(ELEMENT_COLORS) > 0 + + def test_covers_full_periodic_table(self): + assert len(ELEMENT_COLORS) == 118 + + def test_first_and_last_symbols(self): + symbols = list(ELEMENT_COLORS) + assert symbols[0] == 'H' + assert symbols[-1] == 'Og' + + def test_keys_are_titlecase_element_symbols(self): + for symbol in ELEMENT_COLORS: + assert isinstance(symbol, str) + assert 1 <= len(symbol) <= 2 + assert symbol[0].isupper() + assert symbol.istitle() + + +# ------------------------------------------------------------------ +# ELEMENT_COLORS — per-entry contract +# ------------------------------------------------------------------ + + +def _assert_valid_rgb(value, context): + assert isinstance(value, tuple), context + assert len(value) == 3, context + for component in value: + assert isinstance(component, int), context + assert 0 <= component <= 255, context + + +class TestElementColorsEntries: + def test_every_entry_is_a_dict(self): + for entry in ELEMENT_COLORS.values(): + assert isinstance(entry, dict) + + def test_every_entry_has_exactly_the_scheme_keys(self): + expected_keys = set(COLOR_SCHEMES) + for symbol, entry in ELEMENT_COLORS.items(): + assert set(entry) == expected_keys, symbol + + def test_jmol_always_present_and_valid_rgb(self): + # color_for() falls back to the jmol colour, so every element + # must have a usable jmol RGB triple. + for symbol, entry in ELEMENT_COLORS.items(): + assert entry['jmol'] is not None, symbol + _assert_valid_rgb(entry['jmol'], symbol) + + def test_vesta_is_rgb_or_none(self): + for symbol, entry in ELEMENT_COLORS.items(): + value = entry['vesta'] + if value is not None: + _assert_valid_rgb(value, symbol) + + +# ------------------------------------------------------------------ +# ELEMENT_COLORS — spot-checked known values +# ------------------------------------------------------------------ + + +class TestElementColorsKnownValues: + def test_hydrogen_is_white_in_jmol(self): + assert ELEMENT_COLORS['H']['jmol'] == (255, 255, 255) + + def test_oxygen_is_red_in_jmol(self): + assert ELEMENT_COLORS['O']['jmol'] == (255, 13, 13) + + def test_nobelium_has_no_vesta_entry(self): + # No has a jmol colour but no VESTA override (falls back to jmol). + assert ELEMENT_COLORS['No']['vesta'] is None + assert ELEMENT_COLORS['No']['jmol'] == (189, 13, 135) + + def test_some_elements_have_a_vesta_override(self): + # At least one element must carry a non-None VESTA colour, + # otherwise the second scheme would be dead data. + with_vesta = [s for s, e in ELEMENT_COLORS.items() if e['vesta'] is not None] + assert with_vesta + + +# ------------------------------------------------------------------ +# Cross-dictionary consistency +# ------------------------------------------------------------------ + + +class TestRadiiAndColorsConsistency: + def test_same_element_set(self): + # Every element resolvable for a radius must also resolve for a + # colour, and vice versa. + assert set(ELEMENT_RADII) == set(ELEMENT_COLORS) + + def test_same_ordering(self): + assert list(ELEMENT_RADII) == list(ELEMENT_COLORS) diff --git a/tests/unit/easydiffraction/display/structure/assets/test_radii.py b/tests/unit/easydiffraction/display/structure/assets/test_radii.py new file mode 100644 index 000000000..a38aa41a9 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/assets/test_radii.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for per-element radius lookup with covalent fallback.""" + +from __future__ import annotations + +import easydiffraction.display.structure.assets.radii as radii_module +from easydiffraction.display.structure.assets.elements import ELEMENT_RADII +from easydiffraction.display.structure.assets.radii import DEFAULT_RADIUS +from easydiffraction.display.structure.assets.radii import radius_for + + +def test_module_import(): + expected_module_name = 'easydiffraction.display.structure.assets.radii' + actual_module_name = radii_module.__name__ + assert expected_module_name == actual_module_name + + +class TestDefaultRadius: + def test_value(self): + assert DEFAULT_RADIUS == 1.0 + + def test_is_float(self): + assert isinstance(DEFAULT_RADIUS, float) + + +class TestRadiusForDirectModelHit: + """Element present and the requested model has a value: no fallback.""" + + def test_vdw(self): + radius, substituted = radius_for('Fe', 'vdw') + assert radius == ELEMENT_RADII['Fe']['vdw'] + assert radius == 1.72 + assert substituted is False + + def test_covalent(self): + radius, substituted = radius_for('Fe', 'covalent') + assert radius == ELEMENT_RADII['Fe']['covalent'] + assert radius == 1.32 + assert substituted is False + + def test_ionic(self): + radius, substituted = radius_for('Fe', 'ionic') + assert radius == ELEMENT_RADII['Fe']['ionic'] + assert radius == 0.78 + assert substituted is False + + def test_atomic(self): + radius, substituted = radius_for('Fe', 'atomic') + assert radius == ELEMENT_RADII['Fe']['atomic'] + assert radius == 1.4 + assert substituted is False + + def test_returns_tuple_of_float_and_bool(self): + result = radius_for('Fe', 'vdw') + assert isinstance(result, tuple) + assert len(result) == 2 + radius, substituted = result + assert isinstance(radius, float) + assert isinstance(substituted, bool) + + +class TestRadiusForCovalentFallback: + """Element present but the requested model is None: covalent is used.""" + + def test_ionic_missing_falls_back_to_covalent(self): + # H has ionic=None but covalent=0.31. + assert ELEMENT_RADII['H']['ionic'] is None + radius, substituted = radius_for('H', 'ionic') + assert radius == ELEMENT_RADII['H']['covalent'] + assert radius == 0.31 + assert substituted is True + + def test_atomic_missing_falls_back_to_covalent(self): + # He has atomic=None but covalent=0.28. + assert ELEMENT_RADII['He']['atomic'] is None + radius, substituted = radius_for('He', 'atomic') + assert radius == ELEMENT_RADII['He']['covalent'] + assert radius == 0.28 + assert substituted is True + + +class TestRadiusForUnknownElement: + """Element absent from the database: DEFAULT_RADIUS, substituted.""" + + def test_unknown_symbol(self): + radius, substituted = radius_for('Zz', 'vdw') + assert radius == DEFAULT_RADIUS + assert substituted is True + + def test_empty_symbol(self): + radius, substituted = radius_for('', 'vdw') + assert radius == DEFAULT_RADIUS + assert substituted is True + + def test_unknown_element_ignores_model(self): + # Model is irrelevant once the element is unknown. + for model in ('vdw', 'covalent', 'ionic', 'atomic', 'nonsense'): + radius, substituted = radius_for('Qq', model) + assert radius == DEFAULT_RADIUS + assert substituted is True + + +class TestRadiusForUnknownModel: + """Element present but the model key is unknown: covalent fallback.""" + + def test_unknown_model_falls_back_to_covalent(self): + # 'bogus' is not a key in any entry, so entry.get returns None + # and the covalent radius is substituted. + radius, substituted = radius_for('Fe', 'bogus') + assert radius == ELEMENT_RADII['Fe']['covalent'] + assert substituted is True + + +class TestRadiusForDefaultFallback: + """Entry exists, requested model None, and covalent also None. + + No real element has a None covalent radius, so this last-resort + branch is exercised by patching the database with a synthetic + entry whose every radius is None. + """ + + def test_default_radius_when_covalent_is_none(self, monkeypatch): + patched = dict(ELEMENT_RADII) + patched['Xx'] = {'vdw': None, 'covalent': None, 'ionic': None, 'atomic': None} + monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched) + + radius, substituted = radius_for('Xx', 'vdw') + assert radius == DEFAULT_RADIUS + assert substituted is True + + def test_default_radius_when_model_and_covalent_none(self, monkeypatch): + patched = dict(ELEMENT_RADII) + patched['Xx'] = {'vdw': 2.0, 'covalent': None, 'ionic': None, 'atomic': None} + monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched) + + # ionic is None and covalent is None -> last-resort default. + radius, substituted = radius_for('Xx', 'ionic') + assert radius == DEFAULT_RADIUS + assert substituted is True + + def test_still_uses_model_value_when_present(self, monkeypatch): + patched = dict(ELEMENT_RADII) + patched['Xx'] = {'vdw': 2.0, 'covalent': None, 'ionic': None, 'atomic': None} + monkeypatch.setattr(radii_module, 'ELEMENT_RADII', patched) + + # The requested model has a value, so no fallback happens. + radius, substituted = radius_for('Xx', 'vdw') + assert radius == 2.0 + assert substituted is False + + +class TestRadiusForAllRealElements: + """Smoke check across the whole bundled database.""" + + def test_every_element_resolves_to_positive_radius(self): + # A handful of bundled entries store integer literals (e.g. Pa + # covalent=2, Cn vdw=2), and radius_for passes the stored value + # through unchanged, so accept any real number here. + for element in ELEMENT_RADII: + for model in ('vdw', 'covalent', 'ionic', 'atomic'): + radius, substituted = radius_for(element, model) + assert isinstance(radius, (int, float)) + assert radius > 0.0 + assert isinstance(substituted, bool) + + def test_substituted_flag_matches_database(self): + # When the model value is present, substituted must be False; + # otherwise (covalent fallback) it must be True, because no + # bundled element has a None covalent radius. + for element, entry in ELEMENT_RADII.items(): + for model in ('vdw', 'covalent', 'ionic', 'atomic'): + _, substituted = radius_for(element, model) + assert substituted is (entry.get(model) is None) diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py b/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py new file mode 100644 index 000000000..042d5463c --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/renderers/test_ascii.py @@ -0,0 +1,547 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the ASCII single-cell structure renderer.""" + +from __future__ import annotations + +import re + +import numpy as np + +from easydiffraction.display.structure.renderers import ascii as MUT +from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer +from easydiffraction.display.structure.renderers.base import StructureRendererBase +from easydiffraction.display.structure.scene import AdpEllipsoid +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import OccupancyWedge +from easydiffraction.display.structure.scene import OccupancyWedgeSphere +from easydiffraction.display.structure.scene import StructureScene + +# ANSI escape sequence matcher used to strip colour tinting from output. +_ANSI = re.compile(r'\x1b\[[0-9;]*m') + +# A cubic 5 Angstrom cell basis (rows are the cell vectors). +_CUBIC_BASIS = ((5.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 5.0)) + +ALL_FEATURES = frozenset({'atoms', 'cell', 'axes'}) + + +def _strip_ansi(text: str) -> str: + """Remove ANSI colour codes so glyphs can be asserted directly.""" + return _ANSI.sub('', text) + + +def _atom(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'): + return AtomSphere(centre=centre, radius=radius, colour=colour, label=label) + + +def _scene(atoms=(), occupancy_spheres=(), ellipsoids=(), basis=_CUBIC_BASIS): + return StructureScene( + cell_basis=basis, + atoms=tuple(atoms), + occupancy_spheres=tuple(occupancy_spheres), + ellipsoids=tuple(ellipsoids), + ) + + +# ---------------------------------------------------------------------- +# Module + class basics +# ---------------------------------------------------------------------- + + +def test_module_import(): + expected_module_name = 'easydiffraction.display.structure.renderers.ascii' + assert MUT.__name__ == expected_module_name + + +def test_renderer_is_subclass_of_base(): + assert issubclass(AsciiStructureRenderer, StructureRendererBase) + + +def test_renderer_instantiates(): + assert AsciiStructureRenderer() is not None + + +def test_module_constants(): + assert MUT.GLYPH_RAMP == ('·', '•', '●') + assert MUT.GRID_WIDTH == 56 + assert MUT.PAD == 2 + assert MUT.CHAR_ASPECT == 0.5 + + +# ---------------------------------------------------------------------- +# supported_features +# ---------------------------------------------------------------------- + + +def test_supported_features_value(): + renderer = AsciiStructureRenderer() + assert renderer.supported_features() == frozenset({'atoms', 'cell', 'axes'}) + + +def test_supported_features_matches_class_attribute(): + renderer = AsciiStructureRenderer() + assert renderer.supported_features() is AsciiStructureRenderer.SUPPORTED + + +def test_supported_features_is_frozenset(): + assert isinstance(AsciiStructureRenderer().supported_features(), frozenset) + + +# ---------------------------------------------------------------------- +# render — feature gating +# ---------------------------------------------------------------------- + + +def test_render_returns_str(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES) + assert isinstance(out, str) + + +def test_render_with_no_features_is_blank(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=frozenset()) + # No cell, no atoms, no axes: only whitespace rows remain. + assert _strip_ansi(out).strip() == '' + + +def test_render_cell_only_draws_border_not_atoms(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'cell'})) + plain = _strip_ansi(out) + # Cell corners present, no legend (atoms feature is off). + for corner in ('╭', '╮', '╰', '╯'): + assert corner in plain + assert 'Legend:' not in plain + + +def test_render_atoms_only_has_glyph_and_legend_but_no_border(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'atoms'})) + plain = _strip_ansi(out) + assert 'Legend:' in plain + assert any(g in plain for g in MUT.GLYPH_RAMP) + assert '╭' not in plain + + +def test_render_axes_only_adds_vertical_label(): + # With an empty body (no cell, no drawn atoms) only the vertical + # axis header survives; the horizontal label needs a non-blank + # bottom border row to attach to. + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[]), features=frozenset({'axes'})) + plain = _strip_ansi(out) + # Cubic cell: longest axis horizontal, second-longest (b) vertical. + assert 'b' in plain + assert '↑' in plain + + +def test_render_axes_with_atoms_adds_both_labels(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=frozenset({'atoms', 'axes'})) + plain = _strip_ansi(out) + assert '↑' in plain + assert '→' in plain + + +def test_render_all_features_includes_border_atoms_axes_legend(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES) + plain = _strip_ansi(out) + assert '╭' in plain + assert any(g in plain for g in MUT.GLYPH_RAMP) + assert '↑' in plain + assert '→' in plain + assert 'Legend:' in plain + + +def test_render_atoms_feature_without_atoms_skips_legend(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[]), features=ALL_FEATURES) + plain = _strip_ansi(out) + # Atoms requested but the scene has none: border + axes only. + assert 'Legend:' not in plain + assert '╭' in plain + + +def test_render_output_is_ansi_tinted(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom()]), features=ALL_FEATURES) + # Coloured glyphs must carry ANSI escape codes. + assert '\x1b[' in out + + +# ---------------------------------------------------------------------- +# render — scene primitive coverage +# ---------------------------------------------------------------------- + + +def test_render_includes_atom_label_in_legend(): + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(atoms=[_atom(label='Fe1')]), features=ALL_FEATURES) + plain = _strip_ansi(out) + # Digits are stripped from the legend element name. + assert 'Fe' in plain + assert 'Fe1' not in plain + + +def test_render_occupancy_sphere_uses_first_wedge_colour(): + sphere = OccupancyWedgeSphere( + centre=(2.5, 2.5, 2.5), + radius=1.2, + wedges=(OccupancyWedge(0.6, (10, 20, 30)), OccupancyWedge(0.4, (40, 50, 60))), + label='La/Ba', + ) + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(occupancy_spheres=[sphere]), features=ALL_FEATURES) + plain = _strip_ansi(out) + # The slash is preserved so a shared site reads 'La/Ba'. + assert 'La/Ba' in plain + assert 'Legend:' in plain + + +def test_render_ellipsoid_uses_mean_semi_axes(): + ellipsoid = AdpEllipsoid( + centre=(1.0, 1.0, 1.0), + semi_axes=(0.3, 0.5, 0.7), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 200, 0), + label='O', + ) + renderer = AsciiStructureRenderer() + out = renderer.render(_scene(ellipsoids=[ellipsoid]), features=ALL_FEATURES) + plain = _strip_ansi(out) + assert 'O' in plain + assert any(g in plain for g in MUT.GLYPH_RAMP) + + +def test_render_mixed_primitives_all_appear_in_legend(): + atoms = [_atom(centre=(0.0, 0.0, 0.0), label='Fe', colour=(255, 0, 0))] + spheres = [ + OccupancyWedgeSphere( + centre=(2.5, 2.5, 2.5), + radius=1.0, + wedges=(OccupancyWedge(1.0, (0, 0, 255)),), + label='Na', + ) + ] + ellipsoids = [ + AdpEllipsoid( + centre=(4.0, 4.0, 4.0), + semi_axes=(0.4, 0.4, 0.4), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 255, 0), + label='O', + ) + ] + renderer = AsciiStructureRenderer() + out = renderer.render( + _scene(atoms=atoms, occupancy_spheres=spheres, ellipsoids=ellipsoids), + features=ALL_FEATURES, + ) + plain = _strip_ansi(out) + for element in ('Fe', 'Na', 'O'): + assert element in plain + + +def test_render_axes_letters_track_axis_lengths(): + # Make c the longest axis and a the shortest; b stays second-longest. + basis = ((2.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 9.0)) + renderer = AsciiStructureRenderer() + out = renderer.render( + _scene(atoms=[_atom()], basis=basis), + features=frozenset({'atoms', 'axes'}), + ) + plain = _strip_ansi(out) + # Longest (c) horizontal, second-longest (b) vertical. + assert '→ c' in plain + assert 'b' in plain + + +# ---------------------------------------------------------------------- +# _collect_atoms +# ---------------------------------------------------------------------- + + +def test_collect_atoms_flattens_every_primitive(): + sphere = OccupancyWedgeSphere( + centre=(1.0, 1.0, 1.0), + radius=0.9, + wedges=(OccupancyWedge(1.0, (1, 2, 3)),), + label='Na', + ) + ellipsoid = AdpEllipsoid( + centre=(2.0, 2.0, 2.0), + semi_axes=(0.2, 0.4, 0.6), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(4, 5, 6), + label='O', + ) + scene = _scene(atoms=[_atom(label='Fe')], occupancy_spheres=[sphere], ellipsoids=[ellipsoid]) + + flattened = MUT._collect_atoms(scene) + + assert len(flattened) == 3 + labels = [item[3] for item in flattened] + assert labels == ['Fe', 'Na', 'O'] + # Occupancy sphere inherits its first wedge colour. + assert flattened[1][2] == (1, 2, 3) + # Ellipsoid radius is the mean of its semi-axes. + assert flattened[2][1] == np.mean((0.2, 0.4, 0.6)) + + +def test_collect_atoms_ellipsoid_zero_mean_falls_back_to_default_radius(): + ellipsoid = AdpEllipsoid( + centre=(0.0, 0.0, 0.0), + semi_axes=(0.0, 0.0, 0.0), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(7, 8, 9), + label='X', + ) + flattened = MUT._collect_atoms(_scene(ellipsoids=[ellipsoid])) + assert flattened[0][1] == 0.4 + + +def test_collect_atoms_empty_scene_returns_empty_list(): + assert MUT._collect_atoms(_scene()) == [] + + +# ---------------------------------------------------------------------- +# _ansi256 / _tint +# ---------------------------------------------------------------------- + + +def test_ansi256_black_and_white_bounds(): + assert MUT._ansi256((0, 0, 0)) == 16 + assert MUT._ansi256((255, 255, 255)) == 231 + + +def test_ansi256_pure_red(): + # 16 + 36*5 = 196 for full-red, no green, no blue. + assert MUT._ansi256((255, 0, 0)) == 196 + + +def test_tint_wraps_text_in_escape_codes(): + tinted = MUT._tint((255, 0, 0), 'X') + assert tinted.startswith('\x1b[38;5;196m') + assert tinted.endswith('\x1b[0m') + assert _strip_ansi(tinted) == 'X' + + +# ---------------------------------------------------------------------- +# _make_grid +# ---------------------------------------------------------------------- + + +def test_make_grid_size_and_placement(): + points = [(0.0, 0.0), (10.0, 4.0)] + grid, place = MUT._make_grid(points) + height, width = grid['_size'] + assert width == MUT.GRID_WIDTH + assert height >= 8 + # The extreme corners land within the padded interior. + r0, c0 = place((0.0, 0.0)) + r1, c1 = place((10.0, 4.0)) + assert c0 == MUT.PAD + assert MUT.PAD <= c1 <= width - 1 + # Higher y maps to a smaller row (top of the grid). + assert r1 < r0 + + +def test_make_grid_handles_empty_points(): + grid, place = MUT._make_grid([]) + height, width = grid['_size'] + assert width == MUT.GRID_WIDTH + assert height >= 8 + # A degenerate span must not raise and should place at the origin pad. + assert place((0.0, 0.0)) == (MUT.PAD, MUT.PAD) + + +def test_make_grid_handles_zero_span(): + # All points identical: span guards prevent division by zero. + _grid, place = MUT._make_grid([(3.0, 3.0), (3.0, 3.0)]) + assert place((3.0, 3.0)) == (MUT.PAD, MUT.PAD) + + +# ---------------------------------------------------------------------- +# _draw_cell +# ---------------------------------------------------------------------- + + +def test_draw_cell_writes_closed_rectangle_with_corners(): + corners = [(0.0, 0.0), (10.0, 0.0), (0.0, 4.0), (10.0, 4.0)] + grid, place = MUT._make_grid(corners) + MUT._draw_cell(grid, place, corners) + glyphs = {cell: val[0] for cell, val in grid.items() if cell != '_size'} + assert '╭' in glyphs.values() + assert '╮' in glyphs.values() + assert '╰' in glyphs.values() + assert '╯' in glyphs.values() + assert '─' in glyphs.values() + assert '│' in glyphs.values() + + +def test_draw_cell_corner_overrides_border(): + corners = [(0.0, 0.0), (10.0, 0.0), (0.0, 4.0), (10.0, 4.0)] + grid, place = MUT._make_grid(corners) + MUT._draw_cell(grid, place, corners) + cells = [place(c) for c in corners] + rows = [r for r, _ in cells] + cols = [c for _, c in cells] + top_left = (min(rows), min(cols)) + # The shared corner cell is a corner glyph, never a straight edge. + assert grid[top_left][0] == '╭' + + +# ---------------------------------------------------------------------- +# _draw_atoms +# ---------------------------------------------------------------------- + + +def test_draw_atoms_places_glyph_by_radius_bucket(): + # Two atoms: the larger radius gets the densest glyph. + atoms = [ + ((0.0, 0.0, 0.0), 0.2, (255, 0, 0), 'A'), + ((10.0, 4.0, 0.0), 1.0, (0, 0, 255), 'B'), + ] + points = [(0.0, 0.0), (10.0, 4.0)] + grid, place = MUT._make_grid(points) + MUT._draw_atoms(grid, place, atoms) + small = grid[place((0.0, 0.0))] + large = grid[place((10.0, 4.0))] + assert large[0] == '●' # densest bucket for the largest radius + assert MUT.GLYPH_RAMP.index(small[0]) <= MUT.GLYPH_RAMP.index(large[0]) + + +def test_draw_atoms_skips_same_colour_duplicate_in_adjacent_cell(): + # Two same-colour atoms projecting to the same cell: only one drawn. + atoms = [ + ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'), + ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'), + ] + grid, place = MUT._make_grid([(0.0, 0.0)]) + MUT._draw_atoms(grid, place, atoms) + drawn = [cell for cell in grid if cell != '_size'] + assert len(drawn) == 1 + + +def test_draw_atoms_keeps_different_colour_in_same_cell(): + # Different colours are not treated as duplicates. + atoms = [ + ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'A'), + ((0.0, 0.0, 0.0), 1.0, (0, 0, 255), 'B'), + ] + grid, place = MUT._make_grid([(0.0, 0.0)]) + MUT._draw_atoms(grid, place, atoms) + drawn = [cell for cell in grid if cell != '_size'] + # Same cell, different colour: the second overwrites the first, but + # it is not skipped as a duplicate (one occupied cell, last colour). + assert len(drawn) == 1 + assert grid[place((0.0, 0.0))][1] == (0, 0, 255) + + +# ---------------------------------------------------------------------- +# _grid_to_lines +# ---------------------------------------------------------------------- + + +def test_grid_to_lines_dimensions_and_blank_fill(): + grid = {'_size': (3, 6)} + grid[0, 0] = ('●', (255, 0, 0)) + lines = MUT._grid_to_lines(grid) + assert len(lines) == 3 + # Empty rows are right-stripped to the empty string. + assert lines[1] == '' + assert lines[2] == '' + # The tinted glyph survives in the first row. + assert '●' in _strip_ansi(lines[0]) + + +def test_grid_to_lines_rstrips_trailing_blanks(): + grid = {'_size': (1, 10)} + grid[0, 2] = ('•', (1, 2, 3)) + lines = MUT._grid_to_lines(grid) + # Trailing blank cells past the glyph are removed. + assert not lines[0].endswith(' ') + + +# ---------------------------------------------------------------------- +# _annotate_axes +# ---------------------------------------------------------------------- + + +def test_annotate_axes_adds_vertical_header_and_horizontal_label(): + lines = ['row0', 'row1', 'row2'] + out = MUT._annotate_axes(lines, left_col=3, bottom_row=2, v_letter='b', h_letter='a') + # Header: blank line, letter, arrow; all indented by left_col. + assert out[0] == '' + assert out[1] == ' b' + assert out[2] == ' ↑' + # Horizontal label appended to the bottom row. + assert out[-1].endswith('→ a') + + +def test_annotate_axes_out_of_range_bottom_row_leaves_body_unchanged(): + lines = ['only'] + out = MUT._annotate_axes(lines, left_col=0, bottom_row=99, v_letter='c', h_letter='a') + # Header still added, but no horizontal label since row is out of range. + assert out[:3] == ['', 'c', '↑'] + assert out[3] == 'only' + assert '→' not in out[3] + + +# ---------------------------------------------------------------------- +# _legend +# ---------------------------------------------------------------------- + + +def test_legend_strips_digits_keeps_slash(): + atoms = [ + ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'Fe1'), + ((0.0, 0.0, 0.0), 1.0, (0, 0, 255), 'La/Ba'), + ] + legend = MUT._legend(atoms) + plain = _strip_ansi(legend) + assert plain.startswith('Legend:') + assert 'Fe' in plain + assert 'Fe1' not in plain + assert 'La/Ba' in plain + + +def test_legend_deduplicates_by_element(): + atoms = [ + ((0.0, 0.0, 0.0), 1.0, (255, 0, 0), 'Fe1'), + ((1.0, 1.0, 1.0), 1.0, (255, 0, 0), 'Fe2'), + ] + legend = MUT._legend(atoms) + plain = _strip_ansi(legend) + # Two Fe sites collapse to a single 'Fe' legend entry. + assert plain.count('Fe') == 1 + + +def test_legend_glyph_scales_with_radius(): + atoms = [ + ((0.0, 0.0, 0.0), 0.2, (255, 0, 0), 'H'), + ((1.0, 1.0, 1.0), 1.0, (0, 0, 255), 'U'), + ] + legend = MUT._legend(atoms) + plain = _strip_ansi(legend) + # Largest radius element uses the densest glyph. + assert '● U' in plain + assert '· H' in plain or '• H' in plain + + +def test_legend_all_zero_radius_does_not_divide_by_zero(): + atoms = [((0.0, 0.0, 0.0), 0.0, (1, 2, 3), 'X')] + legend = MUT._legend(atoms) + plain = _strip_ansi(legend) + assert 'X' in plain + + +def test_legend_label_with_no_alpha_falls_back_to_label(): + # A purely numeric label keeps the raw label (the 'or label' branch). + atoms = [((0.0, 0.0, 0.0), 1.0, (1, 2, 3), '123')] + legend = MUT._legend(atoms) + plain = _strip_ansi(legend) + assert '123' in plain diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_base.py b/tests/unit/easydiffraction/display/structure/renderers/test_base.py new file mode 100644 index 000000000..239c1d1e2 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/renderers/test_base.py @@ -0,0 +1,199 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for display/structure/renderers/base.py (StructureRendererBase).""" + +from __future__ import annotations + +from abc import ABC + +import pytest + +from easydiffraction.display.structure.renderers.base import StructureRendererBase +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import StructureScene + + +# ------------------------------------------------------------------ +# Test doubles +# ------------------------------------------------------------------ + + +class _CompleteRenderer(StructureRendererBase): + """A minimal concrete renderer overriding both abstract methods.""" + + def render(self, scene: StructureScene, *, features: frozenset[str]) -> str: + return f'atoms={len(scene.atoms)} features={sorted(features)}' + + def supported_features(self) -> frozenset[str]: + return frozenset({'atoms', 'cell'}) + + +class _RenderOnlyRenderer(StructureRendererBase): + """Overrides only ``render`` so the class stays abstract.""" + + def render(self, scene: StructureScene, *, features: frozenset[str]) -> str: + return '' + + +class _FeaturesOnlyRenderer(StructureRendererBase): + """Overrides only ``supported_features`` so the class stays abstract.""" + + def supported_features(self) -> frozenset[str]: + return frozenset() + + +class _DelegatingRenderer(StructureRendererBase): + """Calls the base-class bodies via ``super()`` to hit their raises.""" + + def render(self, scene: StructureScene, *, features: frozenset[str]) -> str: + return super().render(scene, features=features) + + def supported_features(self) -> frozenset[str]: + return super().supported_features() + + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +def _minimal_scene() -> StructureScene: + """Build a tiny renderer-neutral scene without any engine.""" + basis = ( + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + ) + atom = AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.5, colour=(255, 0, 0), label='Fe') + return StructureScene(cell_basis=basis, atoms=(atom,)) + + +# ------------------------------------------------------------------ +# Module / class identity +# ------------------------------------------------------------------ + + +def test_module_import(): + import easydiffraction.display.structure.renderers.base as MUT + + expected_module_name = 'easydiffraction.display.structure.renderers.base' + actual_module_name = MUT.__name__ + assert expected_module_name == actual_module_name + + +def test_is_abstract_base_class(): + assert issubclass(StructureRendererBase, ABC) + + +def test_abstractmethods_are_exactly_render_and_supported_features(): + assert StructureRendererBase.__abstractmethods__ == frozenset({'render', 'supported_features'}) + + +def test_render_marked_abstract(): + assert StructureRendererBase.render.__isabstractmethod__ is True + + +def test_supported_features_marked_abstract(): + assert StructureRendererBase.supported_features.__isabstractmethod__ is True + + +# ------------------------------------------------------------------ +# Instantiation contract +# ------------------------------------------------------------------ + + +def test_cannot_instantiate_base_directly(): + with pytest.raises(TypeError): + StructureRendererBase() + + +def test_cannot_instantiate_with_only_render_overridden(): + with pytest.raises(TypeError): + _RenderOnlyRenderer() + + +def test_cannot_instantiate_with_only_supported_features_overridden(): + with pytest.raises(TypeError): + _FeaturesOnlyRenderer() + + +def test_complete_subclass_instantiates(): + renderer = _CompleteRenderer() + assert isinstance(renderer, StructureRendererBase) + + +# ------------------------------------------------------------------ +# Subclass honours the contract +# ------------------------------------------------------------------ + + +def test_supported_features_returns_frozenset(): + renderer = _CompleteRenderer() + result = renderer.supported_features() + assert isinstance(result, frozenset) + assert result == frozenset({'atoms', 'cell'}) + + +def test_render_returns_string_using_scene_and_features(): + renderer = _CompleteRenderer() + scene = _minimal_scene() + output = renderer.render(scene, features=frozenset({'atoms', 'cell'})) + assert isinstance(output, str) + assert 'atoms=1' in output + assert "features=['atoms', 'cell']" in output + + +def test_render_features_is_keyword_only(): + renderer = _CompleteRenderer() + scene = _minimal_scene() + with pytest.raises(TypeError): + renderer.render(scene, frozenset({'atoms'})) + + +# ------------------------------------------------------------------ +# Base-class method bodies raise NotImplementedError +# ------------------------------------------------------------------ + + +def test_super_render_raises_not_implemented(): + renderer = _DelegatingRenderer() + scene = _minimal_scene() + with pytest.raises(NotImplementedError): + renderer.render(scene, features=frozenset()) + + +def test_super_supported_features_raises_not_implemented(): + renderer = _DelegatingRenderer() + with pytest.raises(NotImplementedError): + renderer.supported_features() + + +# ------------------------------------------------------------------ +# Real concrete renderer satisfies the contract (no engine needed) +# ------------------------------------------------------------------ + + +def test_ascii_renderer_is_a_structure_renderer_base(): + from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer + + renderer = AsciiStructureRenderer() + assert isinstance(renderer, StructureRendererBase) + + +def test_ascii_renderer_supported_features_is_a_subset_of_the_documented_names(): + from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer + + documented = frozenset({'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'}) + features = AsciiStructureRenderer().supported_features() + assert isinstance(features, frozenset) + assert features <= documented + + +def test_ascii_renderer_render_returns_text(): + from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer + + renderer = AsciiStructureRenderer() + scene = _minimal_scene() + output = renderer.render(scene, features=renderer.supported_features()) + assert isinstance(output, str) + assert output != '' diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_raster.py b/tests/unit/easydiffraction/display/structure/renderers/test_raster.py new file mode 100644 index 000000000..bfa426634 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/renderers/test_raster.py @@ -0,0 +1,441 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the z-buffered raster structure renderer.""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest +from PIL import Image + +from easydiffraction.display.structure.renderers import raster as MUT +from easydiffraction.display.structure.renderers.raster import RasterStructureRenderer +from easydiffraction.display.structure.scene import AdpEllipsoid +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import AxisArrow +from easydiffraction.display.structure.scene import AxisTriad +from easydiffraction.display.structure.scene import Bond +from easydiffraction.display.structure.scene import CellEdge +from easydiffraction.display.structure.scene import CellEdges +from easydiffraction.display.structure.scene import LegendEntry +from easydiffraction.display.structure.scene import OccupancyWedge +from easydiffraction.display.structure.scene import OccupancyWedgeSphere +from easydiffraction.display.structure.scene import StructureScene + +# The 8-byte PNG file signature. +PNG_MAGIC = b'\x89PNG\r\n\x1a\n' + +# A simple orthogonal 5x5x5 cell shared by most scenes. +CUBIC_BASIS = ((5.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 5.0)) + + +def _open(png: bytes) -> Image.Image: + """Decode rendered PNG bytes into a Pillow image.""" + return Image.open(io.BytesIO(png)) + + +def _pixels(png: bytes) -> np.ndarray: + """Return the rendered image as an (H, W, 3) uint8 array.""" + return np.asarray(_open(png)) + + +def _has_drawn_pixels(png: bytes) -> bool: + """True when any pixel departs from the white background.""" + return bool((_pixels(png) != 255).any()) + + +def _atom_scene() -> StructureScene: + """A single red atom centred in the cubic cell.""" + return StructureScene( + cell_basis=CUBIC_BASIS, + atoms=(AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'),), + ) + + +def _axis_triad() -> AxisTriad: + """An a/b/c axis triad along the cubic cell edges.""" + return AxisTriad( + origin=(0.0, 0.0, 0.0), + axes=( + AxisArrow(vector=(5.0, 0.0, 0.0), colour=(255, 0, 0), letter='a'), + AxisArrow(vector=(0.0, 5.0, 0.0), colour=(0, 255, 0), letter='b'), + AxisArrow(vector=(0.0, 0.0, 5.0), colour=(0, 0, 255), letter='c'), + ), + ) + + +def _full_scene() -> StructureScene: + """A scene exercising every supported primitive plus a legend.""" + return StructureScene( + cell_basis=CUBIC_BASIS, + atoms=( + AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.8, colour=(255, 0, 0), label='Fe'), + AtomSphere(centre=(5.0, 5.0, 5.0), radius=0.8, colour=(0, 0, 255), label='O'), + ), + occupancy_spheres=( + OccupancyWedgeSphere( + centre=(2.5, 2.5, 2.5), + radius=0.7, + wedges=( + OccupancyWedge(fraction=0.5, colour=(255, 0, 0)), + OccupancyWedge(fraction=0.5, colour=(0, 255, 0)), + ), + label='Mix', + ), + ), + ellipsoids=( + AdpEllipsoid( + centre=(1.0, 1.0, 1.0), + semi_axes=(0.5, 0.4, 0.3), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 200, 0), + label='Ell', + ), + ), + bonds=( + Bond( + start=(0.0, 0.0, 0.0), + end=(5.0, 5.0, 5.0), + start_colour=(255, 0, 0), + end_colour=(0, 0, 255), + ), + ), + cell_edges=CellEdges( + edges=( + CellEdge(start=(0.0, 0.0, 0.0), end=(5.0, 0.0, 0.0)), + CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 5.0, 0.0)), + CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 0.0, 5.0)), + ) + ), + axes=_axis_triad(), + legend=( + LegendEntry(symbol='Fe', colour=(255, 0, 0)), + LegendEntry(symbol='O', colour=(0, 0, 255)), + ), + ) + + +def test_module_import(): + import easydiffraction.display.structure.renderers.raster as MUT + + expected_module_name = 'easydiffraction.display.structure.renderers.raster' + actual_module_name = MUT.__name__ + assert expected_module_name == actual_module_name + + +class TestSupported: + def test_supported_is_frozenset(self): + assert isinstance(RasterStructureRenderer.SUPPORTED, frozenset) + + def test_supported_members(self): + assert frozenset({'atoms', 'bonds', 'cell', 'axes'}) == RasterStructureRenderer.SUPPORTED + + def test_supported_shared_across_instances(self): + # SUPPORTED is a class-level constant, not rebuilt per instance. + assert RasterStructureRenderer().SUPPORTED is RasterStructureRenderer.SUPPORTED + + +class TestConstruction: + def test_instantiation(self): + renderer = RasterStructureRenderer() + assert renderer is not None + + def test_render_png_is_callable(self): + assert callable(RasterStructureRenderer().render_png) + + +class TestRenderPngOutput: + def test_returns_bytes(self): + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert isinstance(png, bytes) + + def test_has_png_signature(self): + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert png[:8] == PNG_MAGIC + + def test_decodes_as_png(self): + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert _open(png).format == 'PNG' + + def test_canvas_dimensions(self): + # The supersampled buffer is downsampled to a fixed 1800x1800 frame. + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert _open(png).size == (1800, 1800) + + def test_rgb_mode(self): + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert _open(png).mode == 'RGB' + + def test_deterministic_for_same_scene(self): + scene = _atom_scene() + first = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + second = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + assert first == second + + +class TestRenderPngFeatures: + def test_atom_is_drawn(self): + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert _has_drawn_pixels(png) + + def test_empty_scene_is_blank(self): + # No primitives and no features -> a pristine white canvas. + scene = StructureScene(cell_basis=CUBIC_BASIS) + png = RasterStructureRenderer().render_png(scene, features=frozenset()) + assert bool((_pixels(png) == 255).all()) + + def test_feature_gating_skips_unrequested_atoms(self): + # The scene has an atom, but 'atoms' is absent from the feature + # set, so nothing is drawn. + scene = _atom_scene() + png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'})) + assert bool((_pixels(png) == 255).all()) + + def test_unknown_feature_names_are_ignored(self): + # Feature names outside SUPPORTED never trigger a draw and never + # raise; only 'atoms' here does any work. + scene = _atom_scene() + png = RasterStructureRenderer().render_png( + scene, features=frozenset({'atoms', 'bogus', 'labels', 'moments'}) + ) + assert _has_drawn_pixels(png) + + def test_cell_only_is_drawn(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + cell_edges=CellEdges(edges=(CellEdge(start=(0.0, 0.0, 0.0), end=(5.0, 0.0, 0.0)),)), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'})) + assert _has_drawn_pixels(png) + + def test_bonds_only_is_drawn(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + bonds=( + Bond( + start=(0.0, 0.0, 0.0), + end=(5.0, 5.0, 5.0), + start_colour=(255, 0, 0), + end_colour=(0, 0, 255), + ), + ), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'bonds'})) + assert _has_drawn_pixels(png) + + def test_axes_only_is_drawn(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + atoms=(AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(0, 0, 0), label='X'),), + axes=_axis_triad(), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'axes'})) + assert _has_drawn_pixels(png) + + def test_axes_feature_without_triad_is_blank(self): + # 'axes' requested but scene.axes is None -> no crash, blank. + scene = StructureScene(cell_basis=CUBIC_BASIS) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'axes'})) + assert bool((_pixels(png) == 255).all()) + + def test_cell_feature_without_edges_is_blank(self): + # 'cell' requested but scene.cell_edges is None -> no crash. + scene = StructureScene(cell_basis=CUBIC_BASIS) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'cell'})) + assert bool((_pixels(png) == 255).all()) + + def test_full_scene_renders(self): + png = RasterStructureRenderer().render_png( + _full_scene(), features=RasterStructureRenderer.SUPPORTED + ) + assert png[:8] == PNG_MAGIC + assert _open(png).size == (1800, 1800) + assert _has_drawn_pixels(png) + + +class TestRenderPngPrimitives: + def test_occupancy_wedge_sphere_renders(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + occupancy_spheres=( + OccupancyWedgeSphere( + centre=(2.5, 2.5, 2.5), + radius=1.0, + wedges=( + OccupancyWedge(fraction=0.6, colour=(255, 0, 0)), + OccupancyWedge(fraction=0.4, colour=(0, 0, 255)), + ), + label='Mix', + ), + ), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + assert _has_drawn_pixels(png) + + def test_ellipsoid_renders(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + ellipsoids=( + AdpEllipsoid( + centre=(2.5, 2.5, 2.5), + semi_axes=(1.0, 0.6, 0.4), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 200, 0), + label='Ell', + ), + ), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + assert _has_drawn_pixels(png) + + def test_ellipsoid_with_wedges_renders(self): + scene = StructureScene( + cell_basis=CUBIC_BASIS, + ellipsoids=( + AdpEllipsoid( + centre=(2.5, 2.5, 2.5), + semi_axes=(1.0, 1.0, 1.0), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 200, 0), + label='Ell', + wedges=( + OccupancyWedge(fraction=0.5, colour=(255, 0, 0)), + OccupancyWedge(fraction=0.5, colour=(0, 0, 255)), + ), + ), + ), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + assert _has_drawn_pixels(png) + + def test_tiny_atom_is_skipped_without_error(self): + # A sub-pixel radius atom is dropped by the minimum-size guard; + # the larger atom still renders and the call succeeds. + scene = StructureScene( + cell_basis=CUBIC_BASIS, + atoms=( + AtomSphere(centre=(0.0, 0.0, 0.0), radius=1.0, colour=(255, 0, 0), label='Fe'), + AtomSphere(centre=(5.0, 5.0, 5.0), radius=1e-6, colour=(0, 0, 255), label='O'), + ), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset({'atoms'})) + assert png[:8] == PNG_MAGIC + assert _has_drawn_pixels(png) + + +class TestLegend: + def test_legend_is_drawn_independent_of_features(self): + # The legend panel is composited regardless of the feature set + # (it is not gated by 'atoms'/'bonds'/...). + scene = StructureScene( + cell_basis=CUBIC_BASIS, + legend=(LegendEntry(symbol='Fe', colour=(255, 0, 0)),), + ) + png = RasterStructureRenderer().render_png(scene, features=frozenset()) + assert _has_drawn_pixels(png) + + def test_no_legend_leaves_top_left_white(self): + # Without a legend, the empty scene stays fully white. + scene = StructureScene(cell_basis=CUBIC_BASIS) + png = RasterStructureRenderer().render_png(scene, features=frozenset()) + assert bool((_pixels(png) == 255).all()) + + +class TestViewBasis: + def test_projection_shifts_content_down_in_report_frame(self): + view_dir = np.array([0.0, 0.0, 1.0]) + right = np.array([1.0, 0.0, 0.0]) + up = np.array([0.0, 1.0, 0.0]) + + _canvas, project, _extent, _pad = RasterStructureRenderer._make_canvas( + _atom_scene(), view_dir, right, up + ) + + _x, y, _depth = project((0.0, 0.0, 0.0)) + size = MUT._CANVAS * MUT._SUPERSAMPLE + assert y == pytest.approx(size * (0.5 + MUT._VERTICAL_SHIFT_FRAC)) + + def test_axis_labels_follow_rendered_arrow_tips(self): + class TextRecorder: + def __init__(self) -> None: + self.calls = [] + + def text(self, xy, text, font, fill, anchor) -> None: + del font, fill, anchor + self.calls.append((xy, text)) + + def project(point: tuple[float, float, float]) -> tuple[float, float, float]: + return ( + (200.0 + point[0] * 100.0) * MUT._SUPERSAMPLE, + (200.0 - point[1] * 100.0) * MUT._SUPERSAMPLE, + point[2], + ) + + draw = TextRecorder() + axes = AxisTriad( + origin=(0.0, 0.0, 0.0), + axes=( + AxisArrow(vector=(6.5, 0.0, 0.0), colour=(255, 0, 0), letter='a'), + AxisArrow(vector=(0.0, 6.5, 0.0), colour=(0, 255, 0), letter='b'), + AxisArrow(vector=(0.0, 0.0, 6.5), colour=(0, 0, 255), letter='c'), + ), + ) + extent = 10.0 + + RasterStructureRenderer._draw_axis_labels(draw, axes, project, extent, 0.0) + + max_axis = 6.5 / 1.3 + overhang = max( + MUT._AXIS_OVERHANG_FRAC * extent, + MUT._AXIS_HEAD_LENGTH_FRAC * extent + MUT._AXIS_GAP_FRAC * extent, + ) + label_x = 200.0 + (6.5 - 0.3 * max_axis + overhang) * 100.0 + label_x += MUT._AXIS_LABEL_GAP_FRAC * extent * 100.0 + a_xy = next(xy for xy, text in draw.calls if text == 'a') + assert a_xy[0] == pytest.approx(label_x) + + def test_anisotropic_cell_renders(self): + # An 8x5x3 cell drives the longest/middle/shortest axis ordering + # in the default-view basis selection. + scene = StructureScene( + cell_basis=((8.0, 0.0, 0.0), (0.0, 5.0, 0.0), (0.0, 0.0, 3.0)), + atoms=( + AtomSphere(centre=(4.0, 2.5, 1.5), radius=1.0, colour=(10, 20, 30), label='X'), + ), + axes=AxisTriad( + origin=(0.0, 0.0, 0.0), + axes=( + AxisArrow(vector=(8.0, 0.0, 0.0), colour=(255, 0, 0), letter='a'), + AxisArrow(vector=(0.0, 5.0, 0.0), colour=(0, 255, 0), letter='b'), + AxisArrow(vector=(0.0, 0.0, 3.0), colour=(0, 0, 255), letter='c'), + ), + ), + ) + png = RasterStructureRenderer().render_png( + scene, features=RasterStructureRenderer.SUPPORTED + ) + assert png[:8] == PNG_MAGIC + assert _has_drawn_pixels(png) + + def test_scene_without_axes_uses_default_basis(self): + # No axis triad -> the fixed fallback camera basis is used; the + # atom still renders. + png = RasterStructureRenderer().render_png(_atom_scene(), features=frozenset({'atoms'})) + assert _has_drawn_pixels(png) + + +class TestRenderPngSignature: + def test_features_is_keyword_only(self): + # render_png(scene, *, features=...) — passing features + # positionally is a TypeError. + with pytest.raises(TypeError): + RasterStructureRenderer().render_png( + StructureScene(cell_basis=CUBIC_BASIS), frozenset() + ) + + def test_features_is_required(self): + # Omitting the keyword-only 'features' argument is a TypeError. + with pytest.raises(TypeError): + RasterStructureRenderer().render_png(StructureScene(cell_basis=CUBIC_BASIS)) diff --git a/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py b/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py new file mode 100644 index 000000000..f951b48c0 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/renderers/test_threejs.py @@ -0,0 +1,782 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the Three.js structure renderer.""" + +from __future__ import annotations + +import json + +import pytest + +from easydiffraction.display.structure.enums import ColorSchemeEnum +from easydiffraction.display.structure.renderers import threejs as MUT +from easydiffraction.display.structure.renderers.base import StructureRendererBase +from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer +from easydiffraction.display.structure.scene import AdpEllipsoid +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import AxisArrow +from easydiffraction.display.structure.scene import AxisTriad +from easydiffraction.display.structure.scene import Bond +from easydiffraction.display.structure.scene import CellEdge +from easydiffraction.display.structure.scene import CellEdges +from easydiffraction.display.structure.scene import LegendEntry +from easydiffraction.display.structure.scene import OccupancyWedge +from easydiffraction.display.structure.scene import OccupancyWedgeSphere +from easydiffraction.display.structure.scene import StructureScene +from easydiffraction.display.structure.scene import TextLabel + +# A representative theme palette returned by the patched ``theme_colors``. +_PATCHED_THEME = {'background': (10, 20, 30), 'foreground': (240, 240, 240)} + +# Identity cell basis shared by the lightweight scenes below. +_IDENTITY_BASIS = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + + +def _identity_scene() -> StructureScene: + """Return the minimal valid scene (only a cell basis).""" + return StructureScene(cell_basis=_IDENTITY_BASIS) + + +def _rich_scene() -> StructureScene: + """Return a scene exercising every primitive the payload reads.""" + atom = AtomSphere( + centre=(0.0, 0.0, 0.0), + radius=0.5, + colour=(255, 0, 0), + label='Fe', + asymmetric=True, + ) + wedge_sphere = OccupancyWedgeSphere( + centre=(0.5, 0.5, 0.5), + radius=0.4, + wedges=( + OccupancyWedge(fraction=0.6, colour=(0, 0, 255)), + OccupancyWedge(fraction=0.4, colour=(210, 210, 210)), + ), + label='La/Ba', + ) + ellipsoid = AdpEllipsoid( + centre=(0.25, 0.25, 0.25), + semi_axes=(0.3, 0.2, 0.1), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 255, 0), + label='O', + wedges=(OccupancyWedge(fraction=1.0, colour=(0, 255, 0)),), + asymmetric=True, + ) + bond = Bond( + start=(0.0, 0.0, 0.0), + end=(0.5, 0.5, 0.5), + start_colour=(255, 0, 0), + end_colour=(0, 0, 255), + start_element='Fe', + end_element='O', + ) + edges = CellEdges( + edges=( + CellEdge(start=(0.0, 0.0, 0.0), end=(1.0, 0.0, 0.0)), + CellEdge(start=(0.0, 0.0, 0.0), end=(0.0, 1.0, 0.0)), + ), + ) + axes = AxisTriad( + origin=(0.0, 0.0, 0.0), + axes=( + AxisArrow(vector=(1.0, 0.0, 0.0), colour=(220, 40, 40), letter='a'), + AxisArrow(vector=(0.0, 1.0, 0.0), colour=(40, 180, 40), letter='b'), + AxisArrow(vector=(0.0, 0.0, 1.0), colour=(40, 80, 220), letter='c'), + ), + ) + return StructureScene( + cell_basis=_IDENTITY_BASIS, + atoms=(atom,), + occupancy_spheres=(wedge_sphere,), + ellipsoids=(ellipsoid,), + bonds=(bond,), + cell_edges=edges, + axes=axes, + labels=(TextLabel(anchor=(0.1, 0.2, 0.3), text='Fe1'),), + legend=( + LegendEntry(symbol='Fe', colour=(255, 0, 0)), + LegendEntry(symbol='O', colour=(0, 255, 0)), + ), + ) + + +@pytest.fixture +def patched_theme(monkeypatch): + """Patch the module-level ``theme_colors`` to a fixed test palette. + + Isolates the renderer's own templating logic from the concrete + ``LIGHT_THEME``/``DARK_THEME`` values so the happy-path assertions + below depend only on the renderer. The un-patched integration with + the real ``theme_colors`` is covered by + :class:`TestRenderUnpatchedIntegration`. + """ + monkeypatch.setattr(MUT, 'theme_colors', lambda *, dark: dict(_PATCHED_THEME)) + + +def test_module_import(): + import easydiffraction.display.structure.renderers.threejs as imported + + expected_module_name = 'easydiffraction.display.structure.renderers.threejs' + assert imported.__name__ == expected_module_name + + +# ------------------------------------------------------------------ +# Module-level constants +# ------------------------------------------------------------------ + + +class TestModuleConstants: + def test_cdn_pins_three_version(self): + assert MUT._CDN == 'https://cdn.jsdelivr.net/npm/three@0.160.0' + + def test_addon_specifiers(self): + assert MUT._ADDON_CONTROLS == 'three/addons/controls/OrbitControls.js' + assert MUT._ADDON_CSS2D == 'three/addons/renderers/CSS2DRenderer.js' + + def test_vendor_dir_points_at_threejs_assets(self): + assert MUT._VENDOR.name == 'threejs' + assert MUT._VENDOR.parent.name == 'vendor' + + def test_vendor_dir_holds_pinned_assets(self): + # The offline import map inlines these three vendored files. + names = {p.name for p in MUT._VENDOR.iterdir()} + assert {'three.module.js', 'OrbitControls.js', 'CSS2DRenderer.js'} <= names + + +# ------------------------------------------------------------------ +# _environment +# ------------------------------------------------------------------ + + +class TestEnvironment: + def test_returns_jinja_environment(self): + from jinja2 import Environment + + assert isinstance(MUT._environment(), Environment) + + def test_autoescape_disabled_for_html_payload(self): + # The payload is pre-escaped JSON; Jinja must not re-escape it. + env = MUT._environment() + assert env.trim_blocks is True + assert env.lstrip_blocks is True + + def test_can_load_structure_template(self): + env = MUT._environment() + template = env.get_template(ThreeJsStructureRenderer.TEMPLATE_NAME) + assert template is not None + + +# ------------------------------------------------------------------ +# _data_url +# ------------------------------------------------------------------ + + +class TestDataUrl: + def test_encodes_file_as_base64_javascript_data_url(self, tmp_path): + import base64 + + source = tmp_path / 'snippet.js' + payload = b'console.log("hi");' + source.write_bytes(payload) + + url = MUT._data_url(source) + + prefix = 'data:text/javascript;base64,' + assert url.startswith(prefix) + decoded = base64.b64decode(url[len(prefix) :]) + assert decoded == payload + + def test_roundtrips_arbitrary_bytes(self, tmp_path): + import base64 + + source = tmp_path / 'bytes.js' + payload = bytes(range(256)) + source.write_bytes(payload) + + url = MUT._data_url(source) + decoded = base64.b64decode(url.split('base64,', 1)[1]) + assert decoded == payload + + +# ------------------------------------------------------------------ +# _import_map +# ------------------------------------------------------------------ + + +class TestImportMap: + def test_online_uses_cdn_urls(self): + mapping = MUT._import_map(offline=False) + + assert mapping['three'] == f'{MUT._CDN}/build/three.module.js' + assert mapping[MUT._ADDON_CONTROLS].startswith(MUT._CDN) + assert mapping[MUT._ADDON_CSS2D].startswith(MUT._CDN) + assert mapping[MUT._ADDON_CONTROLS].endswith('OrbitControls.js') + assert mapping[MUT._ADDON_CSS2D].endswith('CSS2DRenderer.js') + + def test_offline_inlines_data_urls(self): + mapping = MUT._import_map(offline=True) + + prefix = 'data:text/javascript;base64,' + assert mapping['three'].startswith(prefix) + assert mapping[MUT._ADDON_CONTROLS].startswith(prefix) + assert mapping[MUT._ADDON_CSS2D].startswith(prefix) + + def test_both_modes_share_the_same_keys(self): + online = MUT._import_map(offline=False) + offline = MUT._import_map(offline=True) + + expected_keys = {'three', MUT._ADDON_CONTROLS, MUT._ADDON_CSS2D} + assert set(online) == expected_keys + assert set(offline) == expected_keys + + +# ------------------------------------------------------------------ +# _rgb_css +# ------------------------------------------------------------------ + + +class TestRgbCss: + def test_formats_triple_as_css_rgb(self): + assert MUT._rgb_css((1, 2, 3)) == 'rgb(1, 2, 3)' + + def test_handles_channel_bounds(self): + assert MUT._rgb_css((0, 0, 0)) == 'rgb(0, 0, 0)' + assert MUT._rgb_css((255, 255, 255)) == 'rgb(255, 255, 255)' + + def test_formats_triple_and_alpha_as_css_rgba(self): + assert MUT._rgba_css((1, 2, 3), 0.5) == 'rgba(1, 2, 3, 0.5)' + + +# ------------------------------------------------------------------ +# _scene_payload +# ------------------------------------------------------------------ + + +class TestScenePayloadKeys: + def test_payload_exposes_documented_keys(self): + payload = MUT._scene_payload(_rich_scene()) + + expected_keys = { + 'atoms', + 'wedgeSpheres', + 'ellipsoids', + 'bonds', + 'cellEdges', + 'axes', + 'labels', + 'legend', + 'palettes', + } + assert set(payload) == expected_keys + + def test_payload_is_json_serialisable(self): + # The renderer embeds this dict via json.dumps; it must round-trip. + payload = MUT._scene_payload(_rich_scene()) + assert json.loads(json.dumps(payload)) is not None + + +class TestScenePayloadPrimitives: + def test_atoms_carry_geometry_colour_and_flags(self): + payload = MUT._scene_payload(_rich_scene()) + + atom = payload['atoms'][0] + assert atom['centre'] == (0.0, 0.0, 0.0) + assert atom['radius'] == 0.5 + assert atom['colour'] == (255, 0, 0) + assert atom['label'] == 'Fe' + assert atom['asymmetric'] is True + + def test_wedge_spheres_expand_fraction_colour_wedges(self): + payload = MUT._scene_payload(_rich_scene()) + + sphere = payload['wedgeSpheres'][0] + assert sphere['centre'] == (0.5, 0.5, 0.5) + assert sphere['radius'] == 0.4 + assert sphere['label'] == 'La/Ba' + assert sphere['asymmetric'] is False + assert sphere['wedges'] == [ + {'fraction': 0.6, 'colour': (0, 0, 255)}, + {'fraction': 0.4, 'colour': (210, 210, 210)}, + ] + + def test_ellipsoids_use_camelcase_semi_axes_and_row_lists(self): + payload = MUT._scene_payload(_rich_scene()) + + ellipsoid = payload['ellipsoids'][0] + assert ellipsoid['centre'] == (0.25, 0.25, 0.25) + assert ellipsoid['semiAxes'] == (0.3, 0.2, 0.1) + # Orientation rows are converted to plain lists for JSON. + assert ellipsoid['orientation'] == [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + assert ellipsoid['colour'] == (0, 255, 0) + assert ellipsoid['label'] == 'O' + assert ellipsoid['asymmetric'] is True + assert ellipsoid['wedges'] == [{'fraction': 1.0, 'colour': (0, 255, 0)}] + + def test_bonds_split_colour_and_element_at_both_ends(self): + payload = MUT._scene_payload(_rich_scene()) + + bond = payload['bonds'][0] + assert bond['start'] == (0.0, 0.0, 0.0) + assert bond['end'] == (0.5, 0.5, 0.5) + assert bond['startColour'] == (255, 0, 0) + assert bond['endColour'] == (0, 0, 255) + assert bond['startElement'] == 'Fe' + assert bond['endElement'] == 'O' + + def test_cell_edges_expand_to_start_end_segments(self): + payload = MUT._scene_payload(_rich_scene()) + + assert payload['cellEdges'] == [ + {'start': (0.0, 0.0, 0.0), 'end': (1.0, 0.0, 0.0)}, + {'start': (0.0, 0.0, 0.0), 'end': (0.0, 1.0, 0.0)}, + ] + + def test_axes_expose_origin_and_lettered_arrows(self): + payload = MUT._scene_payload(_rich_scene()) + + axes = payload['axes'] + assert axes['origin'] == (0.0, 0.0, 0.0) + letters = [arrow['letter'] for arrow in axes['arrows']] + assert letters == ['a', 'b', 'c'] + assert axes['arrows'][0]['vector'] == (1.0, 0.0, 0.0) + assert axes['arrows'][0]['colour'] == (220, 40, 40) + + def test_labels_expose_anchor_and_text(self): + payload = MUT._scene_payload(_rich_scene()) + + assert payload['labels'] == [{'anchor': (0.1, 0.2, 0.3), 'text': 'Fe1'}] + + def test_labels_fallback_to_atom_primitives(self): + scene = StructureScene( + cell_basis=_IDENTITY_BASIS, + atoms=( + AtomSphere( + centre=(0.0, 0.0, 0.0), + radius=0.5, + colour=(255, 0, 0), + label='Fe', + ), + ), + occupancy_spheres=( + OccupancyWedgeSphere( + centre=(0.5, 0.5, 0.5), + radius=0.4, + wedges=(OccupancyWedge(fraction=1.0, colour=(0, 0, 255)),), + label='La/Ba', + ), + ), + ellipsoids=( + AdpEllipsoid( + centre=(0.25, 0.25, 0.25), + semi_axes=(0.3, 0.2, 0.1), + orientation=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + colour=(0, 255, 0), + label='O', + ), + ), + ) + + payload = MUT._scene_payload(scene) + + assert payload['labels'] == [ + {'anchor': (0.0, 0.0, 0.0), 'text': 'Fe'}, + {'anchor': (0.5, 0.5, 0.5), 'text': 'La/Ba'}, + {'anchor': (0.25, 0.25, 0.25), 'text': 'O'}, + ] + + def test_legend_exposes_symbol_and_colour(self): + payload = MUT._scene_payload(_rich_scene()) + + assert payload['legend'] == [ + {'symbol': 'Fe', 'colour': (255, 0, 0)}, + {'symbol': 'O', 'colour': (0, 255, 0)}, + ] + + +class TestScenePayloadPalettes: + def test_palettes_cover_every_colour_scheme(self): + payload = MUT._scene_payload(_rich_scene()) + + expected_schemes = {scheme.value for scheme in ColorSchemeEnum} + assert set(payload['palettes']) == expected_schemes + + def test_each_palette_maps_every_legend_symbol(self): + payload = MUT._scene_payload(_rich_scene()) + + for scheme in ColorSchemeEnum: + palette = payload['palettes'][scheme.value] + assert set(palette) == {'Fe', 'O'} + for rgb in palette.values(): + assert len(rgb) == 3 + + def test_palette_uses_color_for_lookup(self): + from easydiffraction.display.structure.assets.colors import color_for + + payload = MUT._scene_payload(_rich_scene()) + for scheme in ColorSchemeEnum: + palette = payload['palettes'][scheme.value] + assert palette['Fe'] == color_for('Fe', scheme.value) + + +class TestScenePayloadEmptyScene: + def test_empty_scene_yields_empty_collections(self): + payload = MUT._scene_payload(_identity_scene()) + + assert payload['atoms'] == [] + assert payload['wedgeSpheres'] == [] + assert payload['ellipsoids'] == [] + assert payload['bonds'] == [] + assert payload['cellEdges'] == [] + assert payload['labels'] == [] + assert payload['legend'] == [] + + def test_empty_scene_has_no_axes(self): + payload = MUT._scene_payload(_identity_scene()) + assert payload['axes'] is None + + def test_empty_scene_palettes_are_empty_per_scheme(self): + payload = MUT._scene_payload(_identity_scene()) + + for scheme in ColorSchemeEnum: + assert payload['palettes'][scheme.value] == {} + + +# ------------------------------------------------------------------ +# ThreeJsStructureRenderer — class surface +# ------------------------------------------------------------------ + + +class TestRendererClass: + def test_is_structure_renderer_subclass(self): + assert issubclass(ThreeJsStructureRenderer, StructureRendererBase) + + def test_instantiation(self): + assert ThreeJsStructureRenderer() is not None + + def test_supported_features_returns_frozenset(self): + renderer = ThreeJsStructureRenderer() + features = renderer.supported_features() + assert isinstance(features, frozenset) + + def test_supported_features_match_class_constant(self): + renderer = ThreeJsStructureRenderer() + assert renderer.supported_features() == ThreeJsStructureRenderer.SUPPORTED + + def test_supported_feature_names(self): + expected = frozenset({'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'}) + assert expected == ThreeJsStructureRenderer.SUPPORTED + + def test_template_name(self): + assert ThreeJsStructureRenderer.TEMPLATE_NAME == 'structure.html.j2' + + +# ------------------------------------------------------------------ +# ThreeJsStructureRenderer.render — happy path (theme_colors patched) +# ------------------------------------------------------------------ + + +class TestRenderHtmlDocument: + def test_returns_complete_html_document(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _rich_scene(), + features=frozenset({'atoms', 'bonds'}), + offline=True, + dark=False, + ) + assert isinstance(html, str) + assert '<' in html + assert '>' in html + assert len(html) > 0 + + def test_embeds_unique_container_id(self, patched_theme): + renderer = ThreeJsStructureRenderer() + scene = _rich_scene() + first = renderer.render(scene, features=frozenset({'atoms'}), offline=True, dark=False) + second = renderer.render(scene, features=frozenset({'atoms'}), offline=True, dark=False) + + assert 'crysview-' in first + assert 'crysview-' in second + # Each render mints a fresh uuid4-based container id. + assert first != second + + def test_light_theme_marks_document_light(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + assert 'light' in html + + def test_dark_theme_marks_document_dark(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=True, + ) + assert 'dark' in html + assert '--cv-panel-bg: rgba(10, 20, 30, 0.5);' in html + assert "stroke='%23ebebeb'" in html + + def test_offline_embeds_inlined_module(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + # Offline mode inlines the vendored module as a data URL. + assert 'data:text/javascript;base64,' in html + + def test_online_links_cdn(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=False, + dark=False, + ) + assert MUT._CDN in html + + def test_offline_defaults_to_true(self, patched_theme): + # Omitting ``offline`` must inline assets, not link the CDN. + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + dark=False, + ) + assert 'data:text/javascript;base64,' in html + + def test_dark_none_autodetects_via_is_dark(self, patched_theme, monkeypatch): + calls = [] + + def fake_is_dark(): + calls.append(True) + return True + + monkeypatch.setattr(MUT, 'is_dark', fake_is_dark) + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + ) + assert calls == [True] + assert 'dark' in html + + def test_features_are_embedded_sorted(self, patched_theme, monkeypatch): + captured = {} + + real_dumps = json.dumps + + def spy_dumps(obj, *args, **kwargs): + if isinstance(obj, list): + captured['features'] = obj + return real_dumps(obj, *args, **kwargs) + + monkeypatch.setattr(MUT.json, 'dumps', spy_dumps) + ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset({'bonds', 'atoms', 'cell'}), + offline=True, + dark=False, + ) + assert captured['features'] == ['atoms', 'bonds', 'cell'] + + def test_download_button_is_bottom_right_overlay(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert '
' in html + assert 'right: 10px; bottom: 8px;' in html + assert "iconButton(downloadHost, ICONS.camera, 'Download PNG')" in html + + def test_viewer_is_isolated_from_page_header_stacking(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert 'overflow:hidden;isolation:isolate;z-index:0;' in html + assert 'style="position:absolute;inset:0;z-index:1;"' in html + assert 'position: absolute; z-index: 4; background: var(--cv-panel-bg);' in html + + def test_legend_and_hint_overlay_axis_letters(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _rich_scene(), + features=frozenset({'axes'}), + offline=True, + dark=False, + ) + + assert '.cv-legend { z-index: 5;' in html + assert '.cv-hint {\n position: absolute; z-index: 5;' in html + assert "className = 'cv-axis-letter';" in html + + def test_legend_and_hint_use_half_transparent_background(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert '--cv-panel-bg: rgba(10, 20, 30, 0.5);' in html + assert 'color: var(--cv-panel-fg); background: var(--cv-panel-bg);' in html + + def test_perspective_projection_uses_reduced_field_of_view(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert 'const PERSPECTIVE_FOV_DEG = 30;' in html + assert 'new THREE.PerspectiveCamera(PERSPECTIVE_FOV_DEG,' in html + + def test_reset_view_resets_orthographic_zoom(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert "iconButton(cameraGroup, ICONS.home, 'Reset view');" in html + assert 'perspective.zoom = 1;' in html + assert 'perspective.updateProjectionMatrix();' in html + assert 'ortho.zoom = 1;' in html + assert 'ortho.updateProjectionMatrix();' in html + assert 'camera.position.copy(home);' in html + assert 'controls.target.copy(target);' in html + + def test_axis_view_buttons_flip_camera_to_keep_secondary_axis_up(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _rich_scene(), + features=frozenset({'axes'}), + offline=True, + dark=False, + ) + + assert 'let secondarySource = remaining[1];' in html + assert '[horizontalSource, secondarySource] = [secondarySource, horizontalSource];' in html + assert 'const cameraAxis = viewAxis.clone();' in html + assert 'if (secondary.dot(up) < 0) {' in html + assert 'cameraAxis.negate();' in html + assert 'up.negate();' in html + assert 'viewAlong(cameraAxis, up);' in html + assert 'viewAlong(viewAxis, up);' not in html + + def test_colour_scheme_select_matches_button_height(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _rich_scene(), + features=frozenset({'atoms'}), + offline=True, + dark=False, + ) + + assert 'height: calc(var(--cv-control-h) + 2px);' in html + assert 'min-height: calc(var(--cv-control-h) + 2px);' in html + assert 'max-height: calc(var(--cv-control-h) + 2px);' in html + assert 'display: inline-flex; align-items: center;' in html + assert 'vertical-align: top;' in html + assert 'font-family: inherit;' in html + assert '--cv-axis-letter-size: 18px;\n }\n #' in html + + def test_exposes_host_theme_sync_hook(self, patched_theme): + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + + assert 'root.__crysviewApplyTheme = applyTheme;' in html + assert 'data-md-color-scheme' in html + assert 'data-jp-theme-light' in html + assert "document.body.getAttribute('data-md-color-scheme')" in html + + +# ------------------------------------------------------------------ +# ThreeJsStructureRenderer.render — invalid inputs +# ------------------------------------------------------------------ + + +class TestRenderInvalidInputs: + def test_non_iterable_features_raises_type_error(self, patched_theme): + # ``render`` sorts ``features``; a non-iterable cannot be sorted. + with pytest.raises(TypeError): + ThreeJsStructureRenderer().render( + _identity_scene(), + features=None, # type: ignore[arg-type] + offline=True, + dark=False, + ) + + +# ------------------------------------------------------------------ +# ThreeJsStructureRenderer.render — un-patched theme_colors integration +# ------------------------------------------------------------------ + + +class TestRenderUnpatchedIntegration: + """Exercise ``render`` against the real ``theme_colors`` collaborator. + + These tests deliberately omit the ``patched_theme`` fixture so the + production ``theme_colors(dark=dark)`` call runs for real, proving the + keyword-only signature is invoked correctly and its light/dark + contrast colours reach the document. + """ + + def test_render_returns_html_document(self): + # No ``patched_theme``: the real, keyword-only ``theme_colors`` + # must accept the call and yield a complete HTML document. + html = ThreeJsStructureRenderer().render( + _rich_scene(), + features=frozenset({'atoms', 'bonds'}), + offline=True, + dark=False, + ) + assert isinstance(html, str) + assert '<' in html + assert '>' in html + assert 'crysview-' in html + + def test_light_theme_embeds_transparent_canvas_and_contrast_colours(self): + # ``theme_colors(dark=False)`` returns LIGHT_THEME; its background + # and foreground must be wired into the document for labels. + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=False, + ) + assert '--cv-scene-bg: transparent;' in html + assert '--cv-label-shadow-bg: rgb(255, 255, 255);' in html + assert 'rgb(33, 33, 33)' in html # LIGHT_THEME foreground + assert 'light' in html + + def test_dark_theme_embeds_transparent_canvas_and_contrast_colours(self): + # ``theme_colors(dark=True)`` returns DARK_THEME instead. + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset(), + offline=True, + dark=True, + ) + assert '--cv-scene-bg: transparent;' in html + assert '--cv-label-shadow-bg: rgb(33, 33, 33);' in html + assert 'rgb(235, 235, 235)' in html # DARK_THEME foreground + assert 'dark' in html diff --git a/tests/unit/easydiffraction/display/structure/test_builder.py b/tests/unit/easydiffraction/display/structure/test_builder.py new file mode 100644 index 000000000..d9880da60 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/test_builder.py @@ -0,0 +1,688 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for display/structure/builder.py (scene builder).""" + +from __future__ import annotations + +import numpy as np +import pytest + +from easydiffraction.datablocks.structure.item.base import Structure +from easydiffraction.display.structure import builder as MUT +from easydiffraction.display.structure.builder import ALL_FEATURES +from easydiffraction.display.structure.builder import FeatureAvailability +from easydiffraction.display.structure.builder import build_scene +from easydiffraction.display.structure.builder import structure_feature_availability +from easydiffraction.display.structure.enums import AtomViewEnum +from easydiffraction.display.structure.enums import ColorSchemeEnum +from easydiffraction.display.structure.scene import AdpEllipsoid +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import AxisTriad +from easydiffraction.display.structure.scene import Bond +from easydiffraction.display.structure.scene import CellEdges +from easydiffraction.display.structure.scene import LegendEntry +from easydiffraction.display.structure.scene import OccupancyWedgeSphere +from easydiffraction.display.structure.scene import StructureScene +from easydiffraction.display.structure.scene import TextLabel +from easydiffraction.project.categories.structure_style.default import StructureStyle + +# A view range covering only the asymmetric unit (no lattice images), +# keeping P 1 scene-atom counts deterministic and equal to the number +# of in-range sites. +ASYMMETRIC_UNIT = ((0.0, 0.5), (0.0, 0.5), (0.0, 0.5)) +# The full conventional cell; in P 1 the cell-corner lattice images of a +# site at the origin are deduplicated, so counts grow predictably. +FULL_CELL = ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)) + + +def _make_structure(name='test', *, space_group='P 1'): + """Build a minimal cubic P 1 structure (no calculation engine).""" + structure = Structure(name=name) + structure.cell.length_a = 5.0 + structure.cell.length_b = 5.0 + structure.cell.length_c = 5.0 + structure.space_group.name_h_m = space_group + return structure + + +def _add_atom( + structure, + *, + label, + type_symbol, + fract_x=0.0, + fract_y=0.0, + fract_z=0.0, + occupancy=1.0, + adp_type='Biso', + adp_iso=0.5, +): + structure.atom_sites.create( + label=label, + type_symbol=type_symbol, + fract_x=fract_x, + fract_y=fract_y, + fract_z=fract_z, + occupancy=occupancy, + adp_type=adp_type, + adp_iso=adp_iso, + ) + + +def _two_atom_structure(name='two', *, view_positions=True): + """Two distinct in-range sites (Fe + O) for bond/atom tests.""" + structure = _make_structure(name) + offset = (0.1, 0.1, 0.1) if view_positions else (0.0, 0.0, 0.0) + _add_atom( + structure, + label='Fe1', + type_symbol='Fe', + fract_x=offset[0], + fract_y=offset[1], + fract_z=offset[2], + ) + _add_atom( + structure, + label='O1', + type_symbol='O', + fract_x=offset[0] + 0.2, + fract_y=offset[1], + fract_z=offset[2], + ) + structure._sync_atom_site_aniso() + return structure + + +def _anisotropic_structure(name='aniso'): + """One anisotropic (Uani) Fe site with a non-spherical U tensor. + + Created isotropic first, then flipped to ``Uani`` on the existing + site (the supported edit path) before the aniso components are set. + """ + structure = _make_structure(name) + _add_atom( + structure, + label='Fe1', + type_symbol='Fe', + fract_x=0.1, + fract_y=0.1, + fract_z=0.1, + adp_type='Uiso', + adp_iso=0.01, + ) + structure.atom_sites['Fe1'].adp_type = 'Uani' + structure._sync_atom_site_aniso() + aniso = structure.atom_site_aniso['Fe1'] + aniso.adp_11 = 0.01 + aniso.adp_22 = 0.02 + aniso.adp_33 = 0.03 + return structure + + +# ====================================================================== +# Module import + public surface +# ====================================================================== + + +class TestModule: + def test_module_import(self): + assert MUT.__name__ == 'easydiffraction.display.structure.builder' + + def test_all_features_constant(self): + # The resolved feature names the facade can ask the builder for. + assert set(ALL_FEATURES) == {'atoms', 'bonds', 'cell', 'axes', 'moments', 'labels'} + + def test_all_features_is_tuple(self): + # A stable, immutable ordering for UI listings. + assert isinstance(ALL_FEATURES, tuple) + + +# ====================================================================== +# FeatureAvailability dataclass +# ====================================================================== + + +class TestFeatureAvailability: + def test_construction_and_fields(self): + availability = FeatureAvailability( + available=frozenset({'atoms', 'cell'}), + radius_substitutions=('H', 'He'), + ) + assert availability.available == frozenset({'atoms', 'cell'}) + assert availability.radius_substitutions == ('H', 'He') + + def test_is_frozen(self): + availability = FeatureAvailability(available=frozenset(), radius_substitutions=()) + with pytest.raises((AttributeError, TypeError)): + availability.available = frozenset({'atoms'}) + + +# ====================================================================== +# structure_feature_availability +# ====================================================================== + + +class TestStructureFeatureAvailability: + def test_returns_feature_availability(self): + structure = _two_atom_structure() + result = structure_feature_availability(structure, style=StructureStyle()) + assert isinstance(result, FeatureAvailability) + + def test_populated_structure_supports_all_features(self): + structure = _two_atom_structure() + result = structure_feature_availability(structure, style=StructureStyle()) + assert result.available == frozenset({'atoms', 'bonds', 'cell', 'axes', 'labels'}) + + def test_empty_structure_supports_only_cell_and_axes(self): + # With no atom sites, only the cell box and axis triad are drawable. + structure = _make_structure('empty') + result = structure_feature_availability(structure, style=StructureStyle()) + assert result.available == frozenset({'cell', 'axes'}) + + def test_no_substitutions_for_well_covered_elements(self): + structure = _two_atom_structure() + result = structure_feature_availability(structure, style=StructureStyle()) + assert result.radius_substitutions == () + + def test_reports_ionic_substitution_for_hydrogen(self): + # H has no ionic radius, so the ionic view falls back to covalent + # and reports the substitution. + structure = _make_structure('hydride') + _add_atom(structure, label='H1', type_symbol='H', fract_x=0.1, fract_y=0.1, fract_z=0.1) + structure._sync_atom_site_aniso() + style = StructureStyle() + style.atom_view = 'ionic' + result = structure_feature_availability(structure, style=style) + assert result.radius_substitutions == ('H',) + + def test_substitutions_sorted_and_deduplicated(self): + # Two H atoms and one He atom under the ionic view -> one entry per + # element, alphabetically sorted. + structure = _make_structure('light') + _add_atom(structure, label='H1', type_symbol='H', fract_x=0.1, fract_y=0.1, fract_z=0.1) + _add_atom(structure, label='H2', type_symbol='H', fract_x=0.2, fract_y=0.2, fract_z=0.2) + _add_atom(structure, label='He1', type_symbol='He', fract_x=0.3, fract_y=0.3, fract_z=0.3) + structure._sync_atom_site_aniso() + style = StructureStyle() + style.atom_view = 'ionic' + result = structure_feature_availability(structure, style=style) + assert result.radius_substitutions == ('H', 'He') + + +# ====================================================================== +# build_scene — return type and cell basis +# ====================================================================== + + +class TestBuildSceneBasics: + def test_returns_structure_scene(self): + scene = build_scene( + _two_atom_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset(ALL_FEATURES), + ) + assert isinstance(scene, StructureScene) + + def test_cell_basis_matches_cubic_cell(self): + scene = build_scene( + _two_atom_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset(), + ) + # Cubic 5 angstrom cell -> orthogonal basis with 5 on the diagonal. + basis = np.array(scene.cell_basis) + assert np.allclose(basis, np.diag([5.0, 5.0, 5.0]), atol=1e-6) + + +# ====================================================================== +# build_scene — feature gating +# ====================================================================== + + +class TestBuildSceneFeatureGating: + def _scene(self, features): + return build_scene( + _two_atom_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset(features), + ) + + def test_empty_features_emits_nothing_drawable(self): + scene = self._scene(frozenset()) + assert scene.atoms == () + assert scene.occupancy_spheres == () + assert scene.ellipsoids == () + assert scene.bonds == () + assert scene.labels == () + assert scene.legend == () + assert scene.cell_edges is None + assert scene.axes is None + # cell_basis is always present regardless of features. + assert scene.cell_basis is not None + + def test_atoms_feature_emits_atoms_and_legend(self): + scene = self._scene({'atoms'}) + assert len(scene.atoms) == 2 + assert len(scene.legend) == 2 + # No other features requested. + assert scene.bonds == () + assert scene.cell_edges is None + assert scene.axes is None + assert scene.labels == () + + def test_legend_omitted_without_atoms_feature(self): + scene = self._scene({'cell'}) + assert scene.legend == () + + def test_bonds_feature_emits_bonds(self): + scene = self._scene({'atoms', 'bonds'}) + assert len(scene.bonds) == 1 + + def test_bonds_omitted_without_bonds_feature(self): + scene = self._scene({'atoms'}) + assert scene.bonds == () + + def test_cell_feature_emits_twelve_edges(self): + scene = self._scene({'cell'}) + assert isinstance(scene.cell_edges, CellEdges) + assert len(scene.cell_edges.edges) == 12 + + def test_axes_feature_emits_triad(self): + scene = self._scene({'axes'}) + assert isinstance(scene.axes, AxisTriad) + assert len(scene.axes.axes) == 3 + + def test_labels_feature_emits_one_label_per_atom(self): + scene = self._scene({'labels'}) + assert len(scene.labels) == 2 + assert all(isinstance(label, TextLabel) for label in scene.labels) + + def test_full_features_emit_every_section(self): + scene = self._scene(ALL_FEATURES) + assert len(scene.atoms) == 2 + assert len(scene.bonds) == 1 + assert len(scene.labels) == 2 + assert len(scene.legend) == 2 + assert scene.cell_edges is not None + assert scene.axes is not None + + +# ====================================================================== +# build_scene — atom primitives +# ====================================================================== + + +class TestBuildSceneAtomPrimitives: + def test_ball_view_emits_atom_spheres(self): + style = StructureStyle() + style.atom_view = 'vdw' + scene = build_scene( + _two_atom_structure(), + style=style, + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + assert all(isinstance(atom, AtomSphere) for atom in scene.atoms) + assert all(atom.radius > 0.0 for atom in scene.atoms) + assert scene.ellipsoids == () + + def test_atom_scale_grows_ball_radius(self): + small = StructureStyle() + small.atom_view = 'vdw' + small.atom_scale = 0.2 + large = StructureStyle() + large.atom_view = 'vdw' + large.atom_scale = 0.8 + structure = _two_atom_structure() + scene_small = build_scene( + structure, style=small, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + scene_large = build_scene( + structure, style=large, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + assert scene_large.atoms[0].radius > scene_small.atoms[0].radius + + def test_adp_view_anisotropic_atom_emits_ellipsoid(self): + structure = _anisotropic_structure() + style = StructureStyle() + style.atom_view = 'adp' + scene = build_scene( + structure, style=style, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + assert len(scene.ellipsoids) == 1 + assert scene.atoms == () + ellipsoid = scene.ellipsoids[0] + assert isinstance(ellipsoid, AdpEllipsoid) + assert all(axis > 0.0 for axis in ellipsoid.semi_axes) + + def test_adp_probability_scales_ellipsoid_size(self): + structure = _anisotropic_structure() + low = StructureStyle() + low.atom_view = 'adp' + low.adp_probability = 0.5 + high = StructureStyle() + high.atom_view = 'adp' + high.adp_probability = 0.99 + scene_low = build_scene( + structure, style=low, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + scene_high = build_scene( + structure, style=high, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + # Higher probability -> larger principal semi-axes. + assert scene_high.ellipsoids[0].semi_axes[0] > scene_low.ellipsoids[0].semi_axes[0] + + def test_reference_atom_marked_asymmetric(self): + # The atom at the asymmetric-unit reference position is flagged so + # renderers can offer an asymmetric-unit-only toggle. + structure = _two_atom_structure() + scene = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + assert any(atom.asymmetric for atom in scene.atoms) + + +# ====================================================================== +# build_scene — mixed-occupancy (wedge) sites +# ====================================================================== + + +class TestBuildSceneOccupancyWedges: + def _shared_site_structure(self): + structure = _make_structure('mixed') + _add_atom( + structure, + label='Fe1', + type_symbol='Fe', + fract_x=0.1, + fract_y=0.1, + fract_z=0.1, + occupancy=0.6, + ) + _add_atom( + structure, + label='Mn1', + type_symbol='Mn', + fract_x=0.1, + fract_y=0.1, + fract_z=0.1, + occupancy=0.4, + ) + structure._sync_atom_site_aniso() + return structure + + def test_shared_site_emits_occupancy_wedge_sphere(self): + style = StructureStyle() + style.atom_view = 'vdw' + scene = build_scene( + self._shared_site_structure(), + style=style, + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + assert scene.atoms == () + assert len(scene.occupancy_spheres) == 1 + assert isinstance(scene.occupancy_spheres[0], OccupancyWedgeSphere) + + def test_wedge_label_joins_member_labels(self): + scene = build_scene( + self._shared_site_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + sphere = scene.occupancy_spheres[0] + assert sphere.label == 'Fe1/Mn1' + + def test_wedges_use_relative_proportions(self): + scene = build_scene( + self._shared_site_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + fractions = [wedge.fraction for wedge in scene.occupancy_spheres[0].wedges] + assert pytest.approx(sum(fractions), abs=1e-9) == 1.0 + assert pytest.approx(fractions[0], abs=1e-9) == 0.6 + assert pytest.approx(fractions[1], abs=1e-9) == 0.4 + + +# ====================================================================== +# build_scene — bonds +# ====================================================================== + + +class TestBuildSceneBonds: + def test_single_atom_has_no_bonds(self): + structure = _make_structure('single') + _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.1, fract_y=0.1, fract_z=0.1) + structure._sync_atom_site_aniso() + scene = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'bonds'}), + ) + assert scene.bonds == () + + def test_close_atoms_bond(self): + scene = build_scene( + _two_atom_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'bonds'}), + ) + assert len(scene.bonds) == 1 + bond = scene.bonds[0] + assert isinstance(bond, Bond) + assert {bond.start_element, bond.end_element} == {'Fe', 'O'} + + def test_min_distance_cutoff_suppresses_bond(self): + # Raising the minimum bonded distance above the actual Fe-O + # distance removes the bond. + structure = _two_atom_structure() + structure.geom.min_bond_distance_cutoff = 5.0 + scene = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'bonds'}), + ) + assert scene.bonds == () + + def test_bond_distance_incr_enables_distant_bond(self): + # Two atoms 2.5 angstrom apart (beyond summed covalent radii) + # bond only once the increment is generous enough. + structure = _make_structure('stretch') + _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.0, fract_y=0.0, fract_z=0.0) + _add_atom(structure, label='Fe2', type_symbol='Fe', fract_x=0.5, fract_y=0.0, fract_z=0.0) + structure._sync_atom_site_aniso() + + structure.geom.bond_distance_incr = 0.0 + scene_tight = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'bonds'}), + ) + + structure.geom.bond_distance_incr = 5.0 + scene_loose = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'bonds'}), + ) + + assert len(scene_loose.bonds) >= len(scene_tight.bonds) + assert len(scene_loose.bonds) >= 1 + + +# ====================================================================== +# build_scene — colour scheme + legend +# ====================================================================== + + +class TestBuildSceneColours: + def test_legend_has_one_entry_per_element(self): + scene = build_scene( + _two_atom_structure(), + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + symbols = [entry.symbol for entry in scene.legend] + assert symbols == ['Fe', 'O'] + assert all(isinstance(entry, LegendEntry) for entry in scene.legend) + + def test_colour_scheme_changes_atom_colour(self): + structure = _two_atom_structure() + jmol = StructureStyle() + jmol.atom_view = 'vdw' + jmol.color_scheme = 'jmol' + vesta = StructureStyle() + vesta.atom_view = 'vdw' + vesta.color_scheme = 'vesta' + scene_jmol = build_scene( + structure, style=jmol, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + scene_vesta = build_scene( + structure, style=vesta, view_range=ASYMMETRIC_UNIT, features=frozenset({'atoms'}) + ) + jmol_colours = {atom.label: atom.colour for atom in scene_jmol.atoms} + vesta_colours = {atom.label: atom.colour for atom in scene_vesta.atoms} + # At least one element is coloured differently between schemes. + assert jmol_colours != vesta_colours + + +# ====================================================================== +# build_scene — symmetry expansion +# ====================================================================== + + +class TestBuildSceneSymmetry: + def test_higher_symmetry_generates_more_atoms(self): + # The same single site expands to more scene atoms under a + # higher-symmetry space group than under P 1. + p1 = _make_structure('p1', space_group='P 1') + _add_atom(p1, label='Fe1', type_symbol='Fe', fract_x=0.3, fract_y=0.1, fract_z=0.2) + p1._sync_atom_site_aniso() + scene_p1 = build_scene( + p1, style=StructureStyle(), view_range=FULL_CELL, features=frozenset({'atoms'}) + ) + + cubic = _make_structure('cubic', space_group='P m -3 m') + _add_atom(cubic, label='Fe1', type_symbol='Fe', fract_x=0.3, fract_y=0.1, fract_z=0.2) + cubic._sync_atom_site_aniso() + scene_cubic = build_scene( + cubic, style=StructureStyle(), view_range=FULL_CELL, features=frozenset({'atoms'}) + ) + + n_cubic = ( + len(scene_cubic.atoms) + + len(scene_cubic.occupancy_spheres) + + len(scene_cubic.ellipsoids) + ) + n_p1 = len(scene_p1.atoms) + len(scene_p1.occupancy_spheres) + len(scene_p1.ellipsoids) + assert n_cubic > n_p1 + + def test_p1_emits_one_atom_per_in_range_site(self): + # In P 1 the only operator is the identity, so within the + # asymmetric-unit range each site yields exactly one scene atom. + structure = _make_structure('p1', space_group='P 1') + _add_atom(structure, label='Fe1', type_symbol='Fe', fract_x=0.1, fract_y=0.1, fract_z=0.1) + _add_atom(structure, label='O1', type_symbol='O', fract_x=0.3, fract_y=0.2, fract_z=0.2) + structure._sync_atom_site_aniso() + scene = build_scene( + structure, + style=StructureStyle(), + view_range=ASYMMETRIC_UNIT, + features=frozenset({'atoms'}), + ) + assert len(scene.atoms) == 2 + + +# ====================================================================== +# Value selectors (EnumDescriptor.show_supported) driving the builder +# ====================================================================== + + +class TestValueSelectors: + def test_atom_view_show_supported_lists_all_values(self, capsys): + StructureStyle().atom_view.show_supported() + out = capsys.readouterr().out + for member in AtomViewEnum: + assert member.value in out + + def test_color_scheme_show_supported_lists_all_values(self, capsys): + StructureStyle().color_scheme.show_supported() + out = capsys.readouterr().out + for member in ColorSchemeEnum: + assert member.value in out + + def test_atom_view_enum_backing(self): + # The selector is bound to the closed AtomViewEnum value set. + assert StructureStyle().atom_view.enum is AtomViewEnum + + def test_color_scheme_enum_backing(self): + assert StructureStyle().color_scheme.enum is ColorSchemeEnum + + +# ====================================================================== +# StructureStyle wiring (the style object the builder reads) +# ====================================================================== + + +class TestStructureStyleInputs: + def test_default_atom_view_is_covalent(self): + assert StructureStyle().atom_view.value == AtomViewEnum.COVALENT.value + + def test_default_color_scheme_is_jmol(self): + assert StructureStyle().color_scheme.value == ColorSchemeEnum.JMOL.value + + def test_atom_view_cif_handler_name(self): + assert StructureStyle().atom_view._cif_handler.names == ['_structure_style.atom_view'] + + def test_color_scheme_cif_handler_name(self): + assert StructureStyle().color_scheme._cif_handler.names == [ + '_structure_style.color_scheme' + ] + + def test_invalid_atom_view_rejected(self): + with pytest.raises(ValueError, match='not a valid AtomViewEnum'): + StructureStyle().atom_view = 'not-a-view' + + def test_invalid_color_scheme_rejected(self): + with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'): + StructureStyle().color_scheme = 'not-a-scheme' + + +# ====================================================================== +# Pure helper functions (load-bearing for element/colour mapping) +# ====================================================================== + + +class TestHelpers: + @pytest.mark.parametrize( + ('type_symbol', 'expected'), + [ + ('Fe', 'Fe'), + ('Fe3+', 'Fe'), + ('O2-', 'O'), + ('Cl', 'Cl'), + (' Na+ ', 'Na'), + ], + ) + def test_element_symbol_extraction(self, type_symbol, expected): + assert MUT._element_symbol(type_symbol) == expected + + def test_vec3_casts_to_float_tuple(self): + result = MUT._vec3(np.array([1, 2, 3])) + assert result == (1.0, 2.0, 3.0) + assert all(isinstance(component, float) for component in result) diff --git a/tests/unit/easydiffraction/display/structure/test_enums.py b/tests/unit/easydiffraction/display/structure/test_enums.py new file mode 100644 index 000000000..508f2989e --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/test_enums.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the crysview structure-view enumerations.""" + +from __future__ import annotations + +from enum import StrEnum + +import pytest + +import easydiffraction.display.structure.enums as enums_mod +from easydiffraction.display.structure.enums import AtomViewEnum +from easydiffraction.display.structure.enums import ColorSchemeEnum +from easydiffraction.display.structure.enums import ViewerEngineEnum + + +def test_module_import(): + expected_module_name = 'easydiffraction.display.structure.enums' + assert enums_mod.__name__ == expected_module_name + + +# ---------------------------------------------------------------------- +# ViewerEngineEnum +# ---------------------------------------------------------------------- + + +class TestViewerEngineEnum: + def test_is_str_enum(self): + assert issubclass(ViewerEngineEnum, StrEnum) + + def test_members(self): + assert ViewerEngineEnum.ASCII == 'ascii' + assert ViewerEngineEnum.THREEJS == 'threejs' + + def test_member_count(self): + assert {member.value for member in ViewerEngineEnum} == {'ascii', 'threejs'} + + def test_from_string(self): + assert ViewerEngineEnum('ascii') is ViewerEngineEnum.ASCII + assert ViewerEngineEnum('threejs') is ViewerEngineEnum.THREEJS + + def test_invalid_string(self): + with pytest.raises(ValueError, match='is not a valid ViewerEngineEnum'): + ViewerEngineEnum('opengl') + + def test_default_is_ascii_outside_jupyter(self, monkeypatch): + monkeypatch.setattr(enums_mod, 'in_jupyter', lambda: False) + assert ViewerEngineEnum.default() is ViewerEngineEnum.ASCII + + def test_default_is_threejs_in_jupyter(self, monkeypatch): + monkeypatch.setattr(enums_mod, 'in_jupyter', lambda: True) + assert ViewerEngineEnum.default() is ViewerEngineEnum.THREEJS + + def test_description_ascii(self): + assert ViewerEngineEnum.ASCII.description() == 'Console ASCII schematic structure view' + + def test_description_threejs(self): + assert ViewerEngineEnum.THREEJS.description() == 'Interactive Three.js 3D structure view' + + def test_every_member_has_nonempty_description(self): + for member in ViewerEngineEnum: + assert member.description() + + +# ---------------------------------------------------------------------- +# AtomViewEnum +# ---------------------------------------------------------------------- + + +class TestAtomViewEnum: + def test_is_str_enum(self): + assert issubclass(AtomViewEnum, StrEnum) + + def test_members(self): + assert AtomViewEnum.VDW == 'vdw' + assert AtomViewEnum.COVALENT == 'covalent' + assert AtomViewEnum.IONIC == 'ionic' + assert AtomViewEnum.ADP == 'adp' + + def test_member_count(self): + assert {member.value for member in AtomViewEnum} == { + 'vdw', + 'covalent', + 'ionic', + 'adp', + } + + def test_from_string(self): + assert AtomViewEnum('vdw') is AtomViewEnum.VDW + assert AtomViewEnum('covalent') is AtomViewEnum.COVALENT + assert AtomViewEnum('ionic') is AtomViewEnum.IONIC + assert AtomViewEnum('adp') is AtomViewEnum.ADP + + def test_invalid_string(self): + with pytest.raises(ValueError, match='is not a valid AtomViewEnum'): + AtomViewEnum('atomic') + + def test_default_is_covalent(self): + assert AtomViewEnum.default() is AtomViewEnum.COVALENT + + def test_is_adp_true_only_for_adp(self): + assert AtomViewEnum.ADP.is_adp is True + assert AtomViewEnum.VDW.is_adp is False + assert AtomViewEnum.COVALENT.is_adp is False + assert AtomViewEnum.IONIC.is_adp is False + + def test_radius_model_radius_views_return_own_value(self): + assert AtomViewEnum.VDW.radius_model() == 'vdw' + assert AtomViewEnum.COVALENT.radius_model() == 'covalent' + assert AtomViewEnum.IONIC.radius_model() == 'ionic' + + def test_radius_model_adp_falls_back_to_covalent(self): + assert AtomViewEnum.ADP.radius_model() == AtomViewEnum.COVALENT.value + assert AtomViewEnum.ADP.radius_model() == 'covalent' + + def test_description_per_member(self): + assert AtomViewEnum.VDW.description() == 'Van der Waals radius balls' + assert AtomViewEnum.COVALENT.description() == 'Covalent radius balls' + assert AtomViewEnum.IONIC.description() == 'Ionic (Shannon) radius balls' + assert AtomViewEnum.ADP.description() == 'ADP probability surfaces (spheres / ellipsoids)' + + def test_every_member_has_nonempty_description(self): + for member in AtomViewEnum: + assert member.description() + + +# ---------------------------------------------------------------------- +# ColorSchemeEnum +# ---------------------------------------------------------------------- + + +class TestColorSchemeEnum: + def test_is_str_enum(self): + assert issubclass(ColorSchemeEnum, StrEnum) + + def test_members(self): + assert ColorSchemeEnum.JMOL == 'jmol' + assert ColorSchemeEnum.VESTA == 'vesta' + + def test_member_count(self): + assert {member.value for member in ColorSchemeEnum} == {'jmol', 'vesta'} + + def test_from_string(self): + assert ColorSchemeEnum('jmol') is ColorSchemeEnum.JMOL + assert ColorSchemeEnum('vesta') is ColorSchemeEnum.VESTA + + def test_invalid_string(self): + with pytest.raises(ValueError, match='is not a valid ColorSchemeEnum'): + ColorSchemeEnum('cpk') + + def test_default_is_jmol(self): + assert ColorSchemeEnum.default() is ColorSchemeEnum.JMOL + + def test_description_jmol(self): + assert ColorSchemeEnum.JMOL.description() == 'Jmol / CPK colour scheme' + + def test_description_vesta(self): + assert ColorSchemeEnum.VESTA.description() == 'VESTA colour scheme' + + def test_every_member_has_nonempty_description(self): + for member in ColorSchemeEnum: + assert member.description() diff --git a/tests/unit/easydiffraction/display/structure/test_scene.py b/tests/unit/easydiffraction/display/structure/test_scene.py new file mode 100644 index 000000000..1dcdde0cd --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/test_scene.py @@ -0,0 +1,511 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for renderer-neutral structure scene primitives.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +import easydiffraction.display.structure.scene as scene_module +from easydiffraction.display.structure.scene import AdpEllipsoid +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import AxisArrow +from easydiffraction.display.structure.scene import AxisTriad +from easydiffraction.display.structure.scene import Bond +from easydiffraction.display.structure.scene import CellEdge +from easydiffraction.display.structure.scene import CellEdges +from easydiffraction.display.structure.scene import LegendEntry +from easydiffraction.display.structure.scene import MomentArrow +from easydiffraction.display.structure.scene import OccupancyWedge +from easydiffraction.display.structure.scene import OccupancyWedgeSphere +from easydiffraction.display.structure.scene import StructureScene +from easydiffraction.display.structure.scene import TextLabel + +# ------------------------------------------------------------------ +# Shared minimal building blocks (no engine, no domain types) +# ------------------------------------------------------------------ + +ORIGIN = (0.0, 0.0, 0.0) +IDENTITY_BASIS = ( + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), +) +RED = (255, 0, 0) +GREEN = (0, 255, 0) +BLUE = (0, 0, 255) + +# Every primitive dataclass declared in the module, used by the +# cross-cutting structural tests below. +ALL_PRIMITIVES = ( + AtomSphere, + OccupancyWedge, + OccupancyWedgeSphere, + AdpEllipsoid, + Bond, + MomentArrow, + CellEdge, + CellEdges, + AxisArrow, + AxisTriad, + TextLabel, + LegendEntry, + StructureScene, +) + + +# ------------------------------------------------------------------ +# Module identity and import surface +# ------------------------------------------------------------------ + + +def test_module_import(): + expected_module_name = 'easydiffraction.display.structure.scene' + actual_module_name = scene_module.__name__ + assert expected_module_name == actual_module_name + + +def test_type_aliases_present(): + # The plain coordinate/colour aliases are part of the public surface + # and must stay free of numpy/domain types (they are plain tuples). + assert scene_module.Vec3 == tuple[float, float, float] + assert scene_module.Rgb == tuple[int, int, int] + assert scene_module.Mat3 == tuple[scene_module.Vec3, scene_module.Vec3, scene_module.Vec3] + + +# ------------------------------------------------------------------ +# Cross-cutting dataclass contract: frozen + slots +# ------------------------------------------------------------------ + + +class TestDataclassContract: + """Every primitive is a frozen, slotted dataclass.""" + + @pytest.mark.parametrize('cls', ALL_PRIMITIVES) + def test_is_dataclass(self, cls): + assert dataclasses.is_dataclass(cls) + + @pytest.mark.parametrize('cls', ALL_PRIMITIVES) + def test_is_frozen(self, cls): + params = cls.__dataclass_params__ + assert params.frozen is True + + @pytest.mark.parametrize('cls', ALL_PRIMITIVES) + def test_uses_slots(self, cls): + # slots=True classes have a __slots__ and no per-instance __dict__. + assert hasattr(cls, '__slots__') + assert '__dict__' not in cls.__slots__ + + +# ------------------------------------------------------------------ +# AtomSphere +# ------------------------------------------------------------------ + + +class TestAtomSphere: + def test_construction_and_fields(self): + atom = AtomSphere(centre=(1.0, 2.0, 3.0), radius=0.5, colour=RED, label='Fe') + assert atom.centre == (1.0, 2.0, 3.0) + assert atom.radius == 0.5 + assert atom.colour == RED + assert atom.label == 'Fe' + + def test_asymmetric_defaults_false(self): + atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=BLUE, label='O') + assert atom.asymmetric is False + + def test_asymmetric_can_be_set(self): + atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=BLUE, label='O', asymmetric=True) + assert atom.asymmetric is True + + def test_frozen_rejects_mutation(self): + atom = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe') + with pytest.raises(dataclasses.FrozenInstanceError): + atom.radius = 2.0 + + def test_equality_by_value(self): + a = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe') + b = AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe') + assert a == b + + +# ------------------------------------------------------------------ +# OccupancyWedge +# ------------------------------------------------------------------ + + +class TestOccupancyWedge: + def test_construction_and_fields(self): + wedge = OccupancyWedge(fraction=0.25, colour=GREEN) + assert wedge.fraction == 0.25 + assert wedge.colour == GREEN + + def test_frozen_rejects_mutation(self): + wedge = OccupancyWedge(fraction=0.25, colour=GREEN) + with pytest.raises(dataclasses.FrozenInstanceError): + wedge.fraction = 0.5 + + +# ------------------------------------------------------------------ +# OccupancyWedgeSphere +# ------------------------------------------------------------------ + + +class TestOccupancyWedgeSphere: + def test_construction_and_fields(self): + wedges = ( + OccupancyWedge(fraction=0.7, colour=RED), + OccupancyWedge(fraction=0.3, colour=BLUE), + ) + sphere = OccupancyWedgeSphere( + centre=(0.5, 0.5, 0.5), + radius=0.8, + wedges=wedges, + label='Fe/Mn', + ) + assert sphere.centre == (0.5, 0.5, 0.5) + assert sphere.radius == 0.8 + assert sphere.wedges == wedges + assert sphere.label == 'Fe/Mn' + + def test_asymmetric_defaults_false(self): + sphere = OccupancyWedgeSphere(centre=ORIGIN, radius=1.0, wedges=(), label='X') + assert sphere.asymmetric is False + + def test_asymmetric_can_be_set(self): + sphere = OccupancyWedgeSphere( + centre=ORIGIN, + radius=1.0, + wedges=(), + label='X', + asymmetric=True, + ) + assert sphere.asymmetric is True + + def test_frozen_rejects_mutation(self): + sphere = OccupancyWedgeSphere(centre=ORIGIN, radius=1.0, wedges=(), label='X') + with pytest.raises(dataclasses.FrozenInstanceError): + sphere.radius = 2.0 + + +# ------------------------------------------------------------------ +# AdpEllipsoid +# ------------------------------------------------------------------ + + +class TestAdpEllipsoid: + def test_construction_and_fields(self): + ellipsoid = AdpEllipsoid( + centre=(0.1, 0.2, 0.3), + semi_axes=(0.4, 0.5, 0.6), + orientation=IDENTITY_BASIS, + colour=RED, + label='Fe', + ) + assert ellipsoid.centre == (0.1, 0.2, 0.3) + assert ellipsoid.semi_axes == (0.4, 0.5, 0.6) + assert ellipsoid.orientation == IDENTITY_BASIS + assert ellipsoid.colour == RED + assert ellipsoid.label == 'Fe' + + def test_wedges_default_empty(self): + ellipsoid = AdpEllipsoid( + centre=ORIGIN, + semi_axes=(1.0, 1.0, 1.0), + orientation=IDENTITY_BASIS, + colour=RED, + label='Fe', + ) + assert ellipsoid.wedges == () + + def test_asymmetric_defaults_false(self): + ellipsoid = AdpEllipsoid( + centre=ORIGIN, + semi_axes=(1.0, 1.0, 1.0), + orientation=IDENTITY_BASIS, + colour=RED, + label='Fe', + ) + assert ellipsoid.asymmetric is False + + def test_optional_fields_can_be_set(self): + wedges = (OccupancyWedge(fraction=0.5, colour=RED),) + ellipsoid = AdpEllipsoid( + centre=ORIGIN, + semi_axes=(1.0, 1.0, 1.0), + orientation=IDENTITY_BASIS, + colour=RED, + label='Fe', + wedges=wedges, + asymmetric=True, + ) + assert ellipsoid.wedges == wedges + assert ellipsoid.asymmetric is True + + def test_frozen_rejects_mutation(self): + ellipsoid = AdpEllipsoid( + centre=ORIGIN, + semi_axes=(1.0, 1.0, 1.0), + orientation=IDENTITY_BASIS, + colour=RED, + label='Fe', + ) + with pytest.raises(dataclasses.FrozenInstanceError): + ellipsoid.colour = BLUE + + +# ------------------------------------------------------------------ +# Bond +# ------------------------------------------------------------------ + + +class TestBond: + def test_construction_and_fields(self): + bond = Bond( + start=ORIGIN, + end=(1.0, 1.0, 1.0), + start_colour=RED, + end_colour=BLUE, + ) + assert bond.start == ORIGIN + assert bond.end == (1.0, 1.0, 1.0) + assert bond.start_colour == RED + assert bond.end_colour == BLUE + + def test_element_fields_default_empty(self): + bond = Bond(start=ORIGIN, end=(1.0, 0.0, 0.0), start_colour=RED, end_colour=BLUE) + assert bond.start_element == '' + assert bond.end_element == '' + + def test_element_fields_can_be_set(self): + bond = Bond( + start=ORIGIN, + end=(1.0, 0.0, 0.0), + start_colour=RED, + end_colour=BLUE, + start_element='Fe', + end_element='O', + ) + assert bond.start_element == 'Fe' + assert bond.end_element == 'O' + + def test_frozen_rejects_mutation(self): + bond = Bond(start=ORIGIN, end=(1.0, 0.0, 0.0), start_colour=RED, end_colour=BLUE) + with pytest.raises(dataclasses.FrozenInstanceError): + bond.start_colour = GREEN + + +# ------------------------------------------------------------------ +# MomentArrow +# ------------------------------------------------------------------ + + +class TestMomentArrow: + def test_construction_and_fields(self): + moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED) + assert moment.origin == ORIGIN + assert moment.vector == (0.0, 0.0, 1.0) + assert moment.colour == RED + + def test_frozen_rejects_mutation(self): + moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED) + with pytest.raises(dataclasses.FrozenInstanceError): + moment.vector = (1.0, 0.0, 0.0) + + +# ------------------------------------------------------------------ +# CellEdge / CellEdges +# ------------------------------------------------------------------ + + +class TestCellEdge: + def test_construction_and_fields(self): + edge = CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)) + assert edge.start == ORIGIN + assert edge.end == (1.0, 0.0, 0.0) + + def test_frozen_rejects_mutation(self): + edge = CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)) + with pytest.raises(dataclasses.FrozenInstanceError): + edge.end = (2.0, 0.0, 0.0) + + +class TestCellEdges: + def test_construction_and_fields(self): + edges = ( + CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)), + CellEdge(start=ORIGIN, end=(0.0, 1.0, 0.0)), + ) + cell_edges = CellEdges(edges=edges) + assert cell_edges.edges == edges + assert len(cell_edges.edges) == 2 + + def test_frozen_rejects_mutation(self): + cell_edges = CellEdges(edges=()) + with pytest.raises(dataclasses.FrozenInstanceError): + cell_edges.edges = (CellEdge(start=ORIGIN, end=ORIGIN),) + + +# ------------------------------------------------------------------ +# AxisArrow / AxisTriad +# ------------------------------------------------------------------ + + +class TestAxisArrow: + def test_construction_and_fields(self): + arrow = AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a') + assert arrow.vector == (1.0, 0.0, 0.0) + assert arrow.colour == RED + assert arrow.letter == 'a' + + def test_frozen_rejects_mutation(self): + arrow = AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a') + with pytest.raises(dataclasses.FrozenInstanceError): + arrow.letter = 'b' + + +class TestAxisTriad: + def _triad(self): + return AxisTriad( + origin=ORIGIN, + axes=( + AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a'), + AxisArrow(vector=(0.0, 1.0, 0.0), colour=GREEN, letter='b'), + AxisArrow(vector=(0.0, 0.0, 1.0), colour=BLUE, letter='c'), + ), + ) + + def test_construction_and_fields(self): + triad = self._triad() + assert triad.origin == ORIGIN + assert len(triad.axes) == 3 + assert [arrow.letter for arrow in triad.axes] == ['a', 'b', 'c'] + + def test_frozen_rejects_mutation(self): + triad = self._triad() + with pytest.raises(dataclasses.FrozenInstanceError): + triad.origin = (1.0, 1.0, 1.0) + + +# ------------------------------------------------------------------ +# TextLabel +# ------------------------------------------------------------------ + + +class TestTextLabel: + def test_construction_and_fields(self): + label = TextLabel(anchor=(0.5, 0.5, 0.5), text='Fe1') + assert label.anchor == (0.5, 0.5, 0.5) + assert label.text == 'Fe1' + + def test_frozen_rejects_mutation(self): + label = TextLabel(anchor=ORIGIN, text='Fe1') + with pytest.raises(dataclasses.FrozenInstanceError): + label.text = 'O1' + + +# ------------------------------------------------------------------ +# LegendEntry +# ------------------------------------------------------------------ + + +class TestLegendEntry: + def test_construction_and_fields(self): + entry = LegendEntry(symbol='Fe', colour=RED) + assert entry.symbol == 'Fe' + assert entry.colour == RED + + def test_frozen_rejects_mutation(self): + entry = LegendEntry(symbol='Fe', colour=RED) + with pytest.raises(dataclasses.FrozenInstanceError): + entry.symbol = 'O' + + +# ------------------------------------------------------------------ +# StructureScene (the aggregate root) +# ------------------------------------------------------------------ + + +class TestStructureScene: + def test_minimal_construction_only_basis(self): + scene = StructureScene(cell_basis=IDENTITY_BASIS) + assert scene.cell_basis == IDENTITY_BASIS + + def test_collection_fields_default_empty(self): + scene = StructureScene(cell_basis=IDENTITY_BASIS) + assert scene.atoms == () + assert scene.occupancy_spheres == () + assert scene.ellipsoids == () + assert scene.bonds == () + assert scene.moments == () + assert scene.labels == () + assert scene.legend == () + + def test_optional_singletons_default_none(self): + scene = StructureScene(cell_basis=IDENTITY_BASIS) + assert scene.cell_edges is None + assert scene.axes is None + + def test_fully_populated_scene(self): + atom = AtomSphere(centre=ORIGIN, radius=0.5, colour=RED, label='Fe') + occ = OccupancyWedgeSphere( + centre=(0.5, 0.5, 0.5), + radius=0.6, + wedges=(OccupancyWedge(fraction=1.0, colour=BLUE),), + label='Mn', + ) + ellipsoid = AdpEllipsoid( + centre=(0.25, 0.25, 0.25), + semi_axes=(0.1, 0.2, 0.3), + orientation=IDENTITY_BASIS, + colour=GREEN, + label='O', + ) + bond = Bond(start=ORIGIN, end=(0.5, 0.5, 0.5), start_colour=RED, end_colour=BLUE) + moment = MomentArrow(origin=ORIGIN, vector=(0.0, 0.0, 1.0), colour=RED) + cell_edges = CellEdges(edges=(CellEdge(start=ORIGIN, end=(1.0, 0.0, 0.0)),)) + axes = AxisTriad( + origin=ORIGIN, + axes=( + AxisArrow(vector=(1.0, 0.0, 0.0), colour=RED, letter='a'), + AxisArrow(vector=(0.0, 1.0, 0.0), colour=GREEN, letter='b'), + AxisArrow(vector=(0.0, 0.0, 1.0), colour=BLUE, letter='c'), + ), + ) + label = TextLabel(anchor=ORIGIN, text='Fe1') + legend = (LegendEntry(symbol='Fe', colour=RED),) + + scene = StructureScene( + cell_basis=IDENTITY_BASIS, + atoms=(atom,), + occupancy_spheres=(occ,), + ellipsoids=(ellipsoid,), + bonds=(bond,), + moments=(moment,), + cell_edges=cell_edges, + axes=axes, + labels=(label,), + legend=legend, + ) + + assert scene.atoms == (atom,) + assert scene.occupancy_spheres == (occ,) + assert scene.ellipsoids == (ellipsoid,) + assert scene.bonds == (bond,) + assert scene.moments == (moment,) + assert scene.cell_edges is cell_edges + assert scene.axes is axes + assert scene.labels == (label,) + assert scene.legend == legend + + def test_frozen_rejects_mutation(self): + scene = StructureScene(cell_basis=IDENTITY_BASIS) + with pytest.raises(dataclasses.FrozenInstanceError): + scene.atoms = (AtomSphere(centre=ORIGIN, radius=1.0, colour=RED, label='Fe'),) + + def test_equality_by_value(self): + a = StructureScene(cell_basis=IDENTITY_BASIS) + b = StructureScene(cell_basis=IDENTITY_BASIS) + assert a == b diff --git a/tests/unit/easydiffraction/display/structure/test_viewing.py b/tests/unit/easydiffraction/display/structure/test_viewing.py new file mode 100644 index 000000000..ad423b281 --- /dev/null +++ b/tests/unit/easydiffraction/display/structure/test_viewing.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for display/structure/viewing.py (Viewer and ViewerFactory).""" + +from __future__ import annotations + +import pytest + +from easydiffraction.core.singleton import SingletonBase +from easydiffraction.display.base import RendererBase +from easydiffraction.display.base import RendererFactoryBase +from easydiffraction.display.structure.enums import ViewerEngineEnum +from easydiffraction.display.structure.renderers.ascii import AsciiStructureRenderer +from easydiffraction.display.structure.renderers.threejs import ThreeJsStructureRenderer +from easydiffraction.display.structure.scene import AtomSphere +from easydiffraction.display.structure.scene import StructureScene +from easydiffraction.display.structure.viewing import Viewer +from easydiffraction.display.structure.viewing import ViewerFactory + + +# ------------------------------------------------------------------ +# Test doubles and fixtures +# ------------------------------------------------------------------ + + +class _StubBackend: + """A renderer-shaped stub used to verify facade delegation.""" + + def render(self, scene: StructureScene, *, features: frozenset[str]) -> str: + return f'stub atoms={len(scene.atoms)} features={sorted(features)}' + + def supported_features(self) -> frozenset[str]: + return frozenset({'atoms', 'cell'}) + + +@pytest.fixture +def viewer(monkeypatch): + """Yield a fresh Viewer with the singleton reset before and after.""" + monkeypatch.setattr(Viewer, '_instance', None) + yield Viewer() + monkeypatch.setattr(Viewer, '_instance', None) + + +def _minimal_scene() -> StructureScene: + """Build a tiny renderer-neutral scene without any engine.""" + basis = ( + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + ) + atom = AtomSphere(centre=(0.0, 0.0, 0.0), radius=0.5, colour=(255, 0, 0), label='Fe') + return StructureScene(cell_basis=basis, atoms=(atom,)) + + +# ------------------------------------------------------------------ +# Module / class identity +# ------------------------------------------------------------------ + + +def test_module_import(): + import easydiffraction.display.structure.viewing as MUT + + expected_module_name = 'easydiffraction.display.structure.viewing' + actual_module_name = MUT.__name__ + assert expected_module_name == actual_module_name + + +def test_viewer_is_renderer_base(): + assert issubclass(Viewer, RendererBase) + + +def test_viewer_is_singleton(): + assert issubclass(Viewer, SingletonBase) + + +def test_factory_is_renderer_factory_base(): + assert issubclass(ViewerFactory, RendererFactoryBase) + + +# ------------------------------------------------------------------ +# ViewerFactory +# ------------------------------------------------------------------ + + +class TestViewerFactory: + def test_registry_keys(self): + registry = ViewerFactory._registry() + assert set(registry) == { + ViewerEngineEnum.ASCII.value, + ViewerEngineEnum.THREEJS.value, + } + + def test_registry_entries_have_description_and_class(self): + registry = ViewerFactory._registry() + for config in registry.values(): + assert isinstance(config['description'], str) + assert config['description'] + assert isinstance(config['class'], type) + + def test_supported_engines(self): + engines = ViewerFactory.supported_engines() + assert engines == ['ascii', 'threejs'] + + def test_descriptions_pairs(self): + descriptions = dict(ViewerFactory.descriptions()) + assert descriptions['ascii'] == ViewerEngineEnum.ASCII.description() + assert descriptions['threejs'] == ViewerEngineEnum.THREEJS.description() + + def test_create_ascii(self): + backend = ViewerFactory.create('ascii') + assert isinstance(backend, AsciiStructureRenderer) + + def test_create_threejs(self): + backend = ViewerFactory.create('threejs') + assert isinstance(backend, ThreeJsStructureRenderer) + + def test_create_invalid_raises(self): + with pytest.raises(ValueError, match='Unsupported engine'): + ViewerFactory.create('nonexistent') + + +# ------------------------------------------------------------------ +# Construction / defaults +# ------------------------------------------------------------------ + + +class TestViewerConstruction: + def test_factory_classmethod_returns_viewer_factory(self): + assert Viewer._factory() is ViewerFactory + + def test_default_engine_classmethod_matches_enum_default(self): + assert Viewer._default_engine() == ViewerEngineEnum.default().value + + def test_default_engine_is_a_supported_engine(self): + assert Viewer._default_engine() in ViewerFactory.supported_engines() + + def test_new_instance_uses_default_engine(self, viewer): + assert viewer.engine == Viewer._default_engine() + + def test_new_instance_backend_matches_default_engine(self, viewer): + # Outside Jupyter the default engine is ASCII. + assert viewer.engine == ViewerEngineEnum.ASCII.value + assert isinstance(viewer._backend, AsciiStructureRenderer) + + +# ------------------------------------------------------------------ +# engine property (getter / setter) +# ------------------------------------------------------------------ + + +class TestEngineProperty: + def test_engine_getter_returns_str(self, viewer): + assert isinstance(viewer.engine, str) + + def test_switch_to_threejs_updates_engine_and_backend(self, viewer): + viewer.engine = 'threejs' + assert viewer.engine == 'threejs' + assert isinstance(viewer._backend, ThreeJsStructureRenderer) + + def test_switch_to_ascii_updates_engine_and_backend(self, viewer): + viewer.engine = 'threejs' + viewer.engine = 'ascii' + assert viewer.engine == 'ascii' + assert isinstance(viewer._backend, AsciiStructureRenderer) + + def test_switch_emits_change_notice(self, viewer, capsys): + viewer.engine = 'threejs' + out = capsys.readouterr().out + assert 'threejs' in out.lower() + + def test_setting_same_engine_is_a_noop(self, viewer): + original_backend = viewer._backend + viewer.engine = viewer.engine + # Engine unchanged and backend instance not rebuilt. + assert viewer.engine == ViewerEngineEnum.ASCII.value + assert viewer._backend is original_backend + + def test_invalid_engine_leaves_engine_unchanged(self, viewer): + original_engine = viewer.engine + original_backend = viewer._backend + viewer.engine = 'bogus' + assert viewer.engine == original_engine + assert viewer._backend is original_backend + + def test_invalid_engine_does_not_raise(self, viewer): + # The setter logs a friendly warning instead of raising. + viewer.engine = 'bogus' + + def test_non_string_engine_treated_as_unsupported(self, viewer): + # The setter is not @typechecked: a non-string value is simply an + # unsupported engine, so it warns and leaves the engine unchanged. + original_engine = viewer.engine + original_backend = viewer._backend + viewer.engine = 123 + assert viewer.engine == original_engine + assert viewer._backend is original_backend + + +# ------------------------------------------------------------------ +# render (delegation + real ASCII engine) +# ------------------------------------------------------------------ + + +class TestRender: + def test_render_delegates_to_backend(self, viewer): + viewer._backend = _StubBackend() + scene = _minimal_scene() + output = viewer.render(scene, features=frozenset({'atoms'})) + assert output == "stub atoms=1 features=['atoms']" + + def test_render_features_is_keyword_only(self, viewer): + viewer._backend = _StubBackend() + scene = _minimal_scene() + with pytest.raises(TypeError): + viewer.render(scene, frozenset({'atoms'})) + + def test_render_with_real_ascii_engine_returns_text(self, viewer): + # Default engine is ASCII outside Jupyter; render end to end with + # no calculation engine and no network. + scene = _minimal_scene() + output = viewer.render(scene, features=viewer.supported_features()) + assert isinstance(output, str) + assert output != '' + + +# ------------------------------------------------------------------ +# supported_features (delegation + real engines) +# ------------------------------------------------------------------ + + +class TestSupportedFeatures: + def test_supported_features_delegates_to_backend(self, viewer): + viewer._backend = _StubBackend() + assert viewer.supported_features() == frozenset({'atoms', 'cell'}) + + def test_ascii_supported_features(self, viewer): + assert viewer.engine == ViewerEngineEnum.ASCII.value + features = viewer.supported_features() + assert isinstance(features, frozenset) + assert features == AsciiStructureRenderer.SUPPORTED + + def test_threejs_supported_features(self, viewer): + viewer.engine = 'threejs' + features = viewer.supported_features() + assert isinstance(features, frozenset) + assert features == ThreeJsStructureRenderer.SUPPORTED + + +# ------------------------------------------------------------------ +# show_config and inherited inspection helpers +# ------------------------------------------------------------------ + + +class TestShowHelpers: + def test_show_config_reports_current_engine(self, viewer, capsys): + viewer.show_config() + out = capsys.readouterr().out + assert ViewerEngineEnum.ASCII.value in out.lower() + + def test_show_config_reflects_active_engine(self, viewer, capsys): + viewer.engine = 'threejs' + capsys.readouterr() # drop the engine-change notice + viewer.show_config() + out = capsys.readouterr().out + assert 'threejs' in out.lower() + + def test_show_current_engine_outputs_engine(self, viewer, capsys): + viewer.show_current_engine() + out = capsys.readouterr().out + assert viewer.engine in out.lower() + + def test_show_supported_engines_lists_every_engine(self, viewer, capsys): + viewer.show_supported_engines() + out = capsys.readouterr().out.lower() + for member in ViewerEngineEnum: + assert member.value in out + + +# ------------------------------------------------------------------ +# ViewerEngineEnum (the value selector backing the engine setting) +# ------------------------------------------------------------------ + + +class TestViewerEngineEnum: + def test_members(self): + assert ViewerEngineEnum.ASCII == 'ascii' + assert ViewerEngineEnum.THREEJS == 'threejs' + + def test_default_is_a_member(self): + assert ViewerEngineEnum.default() in set(ViewerEngineEnum) + + def test_every_member_has_a_nonempty_description(self): + for member in ViewerEngineEnum: + description = member.description() + assert isinstance(description, str) + assert description diff --git a/tests/unit/easydiffraction/display/tablers/test_base.py b/tests/unit/easydiffraction/display/tablers/test_base.py index 8dd7d1e92..2402a6bb9 100644 --- a/tests/unit/easydiffraction/display/tablers/test_base.py +++ b/tests/unit/easydiffraction/display/tablers/test_base.py @@ -48,8 +48,9 @@ def test_rich_border_color_property(self): assert isinstance(color, str) def test_pandas_border_color_property(self): + from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR from easydiffraction.display.tablers.rich import RichTableBackend backend = RichTableBackend() color = backend._pandas_border_color - assert color.startswith('#') + assert color == DARK_AXIS_FRAME_COLOR diff --git a/tests/unit/easydiffraction/display/tablers/test_pandas.py b/tests/unit/easydiffraction/display/tablers/test_pandas.py index e4a77c5b6..f8b5d93fe 100644 --- a/tests/unit/easydiffraction/display/tablers/test_pandas.py +++ b/tests/unit/easydiffraction/display/tablers/test_pandas.py @@ -8,14 +8,18 @@ class TestPandasTableBackend: def test_build_base_styles(self): + from easydiffraction.display.tablers.pandas import PANDAS_AXIS_FRAME_COLOR from easydiffraction.display.tablers.pandas import PandasTableBackend backend = PandasTableBackend() - styles = backend._build_base_styles('#aabbcc') + styles = backend._build_base_styles(PANDAS_AXIS_FRAME_COLOR) assert isinstance(styles, list) assert len(styles) > 0 selectors = [s['selector'] for s in styles] assert 'thead' in selectors + assert any( + PANDAS_AXIS_FRAME_COLOR in value for style in styles for _, value in style['props'] + ) def test_build_header_alignment_styles(self): from easydiffraction.display.tablers.pandas import PandasTableBackend @@ -35,7 +39,9 @@ def test_apply_styling_returns_styler(self): assert hasattr(styler, 'to_html') def test_build_renderable_returns_html(self): + from easydiffraction.display.tablers.pandas import PANDAS_TABLE_THEME_CLASS from easydiffraction.display.tablers.pandas import PandasTableBackend + from easydiffraction.display.theme import TABLE_AXIS_FRAME_CSS_VAR pytest.importorskip('jinja2') backend = PandasTableBackend() @@ -45,3 +51,6 @@ def test_build_renderable_returns_html(self): assert isinstance(html, str) assert 'scale', + 'phase.
cell.
length_c', + 'phase.
scale', + 'phase.
cell.
length_c', + ], + cell_size_pixels=Plotter._correlation_cell_size_pixels(), + cap_width=True, + )['fixed_aspect_wrapper'] assert ( fig.layout.meta['fixed_aspect_wrapper']['aspect_ratio'] - == Plotter._square_matrix_layout_meta( - n_parameters=2, - annotation_labels=[ - 'phase.
scale', - 'phase.
cell.
length_c', - 'phase.
scale', - 'phase.
cell.
length_c', - ], - )['fixed_aspect_wrapper']['aspect_ratio'] + == correlation_wrapper_meta['aspect_ratio'] + ) + # Cells are capped to ~16 label characters wide via the wrapper max-width. + assert ( + fig.layout.meta['fixed_aspect_wrapper']['max_width_pixels'] + == correlation_wrapper_meta['max_width_pixels'] ) + theme_sync = fig.layout.meta['ed_plotly_theme_sync'] + assert theme_sync['correlation_heatmap'] is True + assert theme_sync['axis_frame_shape_indexes'] == list(range(len(fig.layout.shapes))) assert fig.layout.xaxis.showline is False assert fig.layout.xaxis.mirror is False assert fig.layout.yaxis.showline is False @@ -2119,6 +2146,9 @@ class Project: assert fig.layout.plot_bgcolor is None assert len(fig.layout.shapes) == 3 assert all(shape.type == 'rect' for shape in fig.layout.shapes) + assert {shape.line.color for shape in fig.layout.shapes} == { + plotly_mod.PlotlyPlotter._axis_frame_color(), + } def test_plot_param_correlations_plotly_labels_respect_threshold(monkeypatch): diff --git a/tests/unit/easydiffraction/display/test_theme.py b/tests/unit/easydiffraction/display/test_theme.py new file mode 100644 index 000000000..e1ce1b99e --- /dev/null +++ b/tests/unit/easydiffraction/display/test_theme.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + + +def test_display_theme_colors_returns_light_and_dark_constants(): + import easydiffraction.display.theme as theme + + light = theme.display_theme_colors(is_dark_theme=False) + dark = theme.display_theme_colors(is_dark_theme=True) + + assert light.background == theme.LIGHT_BACKGROUND_COLOR + assert light.axis_frame == theme.LIGHT_AXIS_FRAME_COLOR + assert light.inner_tick_grid == theme.LIGHT_INNER_TICK_GRID_COLOR + assert dark.background == theme.DARK_BACKGROUND_COLOR + assert dark.axis_frame == theme.DARK_AXIS_FRAME_COLOR + assert dark.inner_tick_grid == theme.DARK_INNER_TICK_GRID_COLOR + + +def test_display_theme_colors_for_template_maps_plotly_templates(): + import easydiffraction.display.theme as theme + + assert theme.display_theme_colors_for_template('plotly_white') is theme.LIGHT_THEME_COLORS + assert theme.display_theme_colors_for_template('plotly_dark') is theme.DARK_THEME_COLORS + assert theme.display_theme_colors_for_template('custom') is None diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py index 12217863d..6ba55e93e 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py @@ -277,6 +277,8 @@ def test_write_iucr_cif_writes_global_block(tmp_path): assert '_computing.structure_refinement' in text assert '_easydiffraction_software.framework' in text assert '_easydiffraction_software.minimizer' in text + assert '_journal.' not in text + assert '_publ_' not in text def test_write_iucr_cif_emits_single_crystal_block(tmp_path): @@ -312,15 +314,20 @@ def test_write_iucr_cif_emits_powder_cwl_blocks(tmp_path): text = write_iucr_cif(project).read_text(encoding='utf-8') - assert 'data_powder_overall' in text - assert 'data_powder_phase_1' in text - assert 'data_powder_pwd_1' in text + assert 'data_overall' in text + assert 'data_phase1' in text + assert 'data_cwl' in text + assert '_pd_block_id phase1' in text + assert '_pd_block_diffractogram_id cwl' in text + assert '|phase1|' not in text + assert '|cwl|' not in text assert '_pd_meas.2theta_scan' in text assert '_pd_meas.time_of_flight' not in text assert '_pd_refln.phase_id' in text assert '_refln.phase_calc' not in text assert '_pd_proc.info_excluded_regions' in text assert '_easydiffraction_background.type' in text + assert '_pd_meas.info_author_' not in text def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path): @@ -338,14 +345,56 @@ def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path): text = write_iucr_cif(project).read_text(encoding='utf-8') - assert 'data_joint_pwd_1' in text - assert 'data_joint_pwd_2' in text + assert 'data_tof1' in text + assert 'data_tof2' in text + assert '\n_pd_block_diffractogram_id\n tof1\n tof2\n' in text assert '_pd_meas.time_of_flight' in text assert '_pd_calib_d_to_tof.power' in text assert 'recip' in text assert '-1' in text +def test_write_iucr_cif_keeps_powder_block_names_unique(tmp_path): + from easydiffraction.io.cif.iucr_writer import write_iucr_cif + + project = _project( + 'demo', + tmp_path, + _collection(_structure(name='overall')), + _collection(_powder_experiment('overall')), + ) + + text = write_iucr_cif(project).read_text(encoding='utf-8') + + assert 'data_overall' in text + assert 'data_overall_2' in text + assert 'data_overall_3' in text + + +def test_write_iucr_cif_keeps_mixed_topology_block_names_unique(tmp_path): + from easydiffraction.io.cif.iucr_writer import write_iucr_cif + + project = _project( + 'mixed', + tmp_path, + _collection(_structure(name='phase1')), + _collection( + _single_crystal_experiment('sc'), + _powder_experiment('cwl'), + ), + ) + + text = write_iucr_cif(project).read_text(encoding='utf-8') + + block_names = [ + line.removeprefix('data_') for line in text.splitlines() if line.startswith('data_') + ] + assert len(block_names) == len(set(block_names)) + assert 'phase1' in block_names + assert 'phase1_2' in block_names + assert '_pd_block_id phase1_2' in text + + def test_iucr_loop_rows_are_not_padded_to_tag_width(): from easydiffraction.io.cif.iucr_writer import _write_loop diff --git a/tests/unit/easydiffraction/project/categories/chart/test_default.py b/tests/unit/easydiffraction/project/categories/chart/test_default.py deleted file mode 100644 index 6cf297dec..000000000 --- a/tests/unit/easydiffraction/project/categories/chart/test_default.py +++ /dev/null @@ -1,104 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import gemmi - - -def test_chart_defaults(): - from easydiffraction.display.plotting import PlotterEngineEnum - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - - assert chart.type_info.tag == 'default' - assert chart._identity.category_code == 'chart' - assert chart.type == 'auto' - assert chart.plotter.engine in [member.value for member in PlotterEngineEnum] - - -def test_chart_plotter_binds_parent(): - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - parent = object() - chart._parent = parent - - plotter = chart.plotter - - assert plotter._project is parent - - -def test_chart_selector_updates_engine(): - from easydiffraction.display.plotting import PlotterEngineEnum - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - - chart._set_type('plotly') - - assert chart.type == 'plotly' - assert chart.plotter.engine == 'plotly' - - chart._set_type('auto') - - assert chart.type == 'auto' - assert chart.plotter.engine == PlotterEngineEnum.default().value - - -def test_chart_from_cif_restores_type(): - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - - swapped: list[tuple[str, dict]] = [] - - class _Parent: - def _swap_chart(self, new_type, *, strict): - swapped.append((new_type, {'strict': strict})) - chart._set_type(new_type, strict=strict) - - chart._parent = _Parent() - block = gemmi.cif.read_string( - 'data_test\n_chart.type plotly\n', - ).sole_block() - - chart.from_cif(block) - - assert swapped == [('plotly', {'strict': False})] - assert chart.type == 'plotly' - - -def test_chart_invalid_type_assignment_raises(): - import pytest - - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - initial_type = chart.type - - with pytest.raises(ValueError, match='Unsupported chart type'): - chart._set_type('bogus-engine') - - assert chart.type == initial_type - - -def test_chart_from_cif_tolerates_invalid_type(monkeypatch): - from easydiffraction.project.categories.chart import default as chart_mod - from easydiffraction.project.categories.chart.default import Chart - - chart = Chart() - chart._parent = type( - 'P', - (), - {'_swap_chart': lambda self, t, *, strict: chart._set_type(t, strict=strict)}, - )() - block = gemmi.cif.read_string( - 'data_test\n_chart.type bogus-engine\n', - ).sole_block() - - warnings: list[str] = [] - monkeypatch.setattr(chart_mod.log, 'warning', warnings.append) - chart.from_cif(block) - - assert chart.type == 'auto' - assert any('Unsupported chart type' in w for w in warnings) diff --git a/tests/unit/easydiffraction/project/categories/chart/test_factory.py b/tests/unit/easydiffraction/project/categories/chart/test_factory.py deleted file mode 100644 index e659835fa..000000000 --- a/tests/unit/easydiffraction/project/categories/chart/test_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import pytest - - -def test_chart_factory_default_and_create(): - from easydiffraction.project.categories.chart.default import Chart - from easydiffraction.project.categories.chart.factory import ChartFactory - - assert ChartFactory.default_tag() == 'default' - assert 'default' in ChartFactory.supported_tags() - - chart = ChartFactory.create('default') - - assert isinstance(chart, Chart) - - -def test_chart_factory_rejects_unknown_tag(): - from easydiffraction.project.categories.chart.factory import ChartFactory - - with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): - ChartFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/publication/test_default.py b/tests/unit/easydiffraction/project/categories/publication/test_default.py deleted file mode 100644 index 4fe0b4462..000000000 --- a/tests/unit/easydiffraction/project/categories/publication/test_default.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - - -def test_publication_instantiates_and_serializes_to_cif(): - from easydiffraction.project.categories.publication.default import Publication - - publication = Publication() - publication.body.title = 'Refinement report' - publication.authors.add(name='Ada Lovelace') - - cif_text = publication.as_cif - - assert not cif_text.startswith('data_') - assert '_publ_body.title' in cif_text - assert 'Refinement report' in cif_text - assert '_publ_author.name' in cif_text - assert 'Ada Lovelace' in cif_text diff --git a/tests/unit/easydiffraction/project/categories/publication/test_factory.py b/tests/unit/easydiffraction/project/categories/publication/test_factory.py deleted file mode 100644 index 872399697..000000000 --- a/tests/unit/easydiffraction/project/categories/publication/test_factory.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -import pytest - - -def test_publication_factory_default_and_create(): - from easydiffraction.project.categories.publication.default import Publication - from easydiffraction.project.categories.publication.factory import PublicationFactory - - assert PublicationFactory.default_tag() == 'default' - assert 'default' in PublicationFactory.supported_tags() - - publication = PublicationFactory.create('default') - - assert isinstance(publication, Publication) - - -def test_publication_factory_rejects_unknown_tag(): - from easydiffraction.project.categories.publication.factory import PublicationFactory - - with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): - PublicationFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py new file mode 100644 index 000000000..8686b65e3 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/rendering_plot/test_default.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gemmi + + +def test_rendering_plot_defaults(): + from easydiffraction.display.plotting import PlotterEngineEnum + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + + rendering_plot = RenderingPlot() + + assert rendering_plot.type_info.tag == 'default' + assert rendering_plot._identity.category_code == 'rendering_plot' + assert rendering_plot.type == 'auto' + assert rendering_plot.plotter.engine in [member.value for member in PlotterEngineEnum] + + +def test_rendering_plot_plotter_binds_parent(): + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + + rendering_plot = RenderingPlot() + parent = object() + rendering_plot._parent = parent + + plotter = rendering_plot.plotter + + assert plotter._project is parent + + +def test_rendering_plot_selector_updates_engine(): + from easydiffraction.display.plotting import PlotterEngineEnum + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + + rendering_plot = RenderingPlot() + + rendering_plot._set_type('plotly') + + assert rendering_plot.type == 'plotly' + assert rendering_plot.plotter.engine == 'plotly' + + rendering_plot._set_type('auto') + + assert rendering_plot.type == 'auto' + assert rendering_plot.plotter.engine == PlotterEngineEnum.default().value + + +def test_rendering_plot_from_cif_restores_type(): + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + + rendering_plot = RenderingPlot() + + swapped: list[tuple[str, dict]] = [] + + class _Parent: + def _swap_rendering_plot(self, new_type, *, strict): + swapped.append((new_type, {'strict': strict})) + rendering_plot._set_type(new_type, strict=strict) + + rendering_plot._parent = _Parent() + block = gemmi.cif.read_string( + 'data_test\n_rendering_plot.type plotly\n', + ).sole_block() + + rendering_plot.from_cif(block) + + assert swapped == [('plotly', {'strict': False})] + assert rendering_plot.type == 'plotly' + + +def test_rendering_plot_invalid_type_assignment_raises(): + import pytest + + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + + rendering_plot = RenderingPlot() + initial_type = rendering_plot.type + + with pytest.raises(ValueError, match='Unsupported rendering_plot type'): + rendering_plot._set_type('bogus-engine') + + assert rendering_plot.type == initial_type + + +def test_rendering_plot_from_cif_tolerates_invalid_type(monkeypatch): + from easydiffraction.project.categories.rendering_plot import default as rendering_plot_mod + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + from easydiffraction.utils.logging import Logger + + # The descriptor's own from_cif validation routes through the + # Logger; force WARN so it logs rather than raises (another test + # may have left the Logger in RAISE mode). + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + + rendering_plot = RenderingPlot() + rendering_plot._parent = type( + 'P', + (), + { + '_swap_rendering_plot': lambda self, t, *, strict: rendering_plot._set_type( + t, strict=strict + ) + }, + )() + block = gemmi.cif.read_string( + 'data_test\n_rendering_plot.type bogus-engine\n', + ).sole_block() + + warnings: list[str] = [] + monkeypatch.setattr(rendering_plot_mod.log, 'warning', warnings.append) + rendering_plot.from_cif(block) + + assert rendering_plot.type == 'auto' + assert any('Unsupported rendering_plot type' in w for w in warnings) diff --git a/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py new file mode 100644 index 000000000..46f8c8944 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/rendering_plot/test_factory.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_rendering_plot_factory_default_and_create(): + from easydiffraction.project.categories.rendering_plot.default import RenderingPlot + from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory + + assert RenderingPlotFactory.default_tag() == 'default' + assert 'default' in RenderingPlotFactory.supported_tags() + + rendering_plot = RenderingPlotFactory.create('default') + + assert isinstance(rendering_plot, RenderingPlot) + + +def test_rendering_plot_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.rendering_plot.factory import RenderingPlotFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + RenderingPlotFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py new file mode 100644 index 000000000..68152b9a1 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/rendering_structure/test_default.py @@ -0,0 +1,394 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for rendering_structure category (default switchable engine).""" + +from __future__ import annotations + +import gemmi +import pytest + +from easydiffraction.display.structure.enums import ViewerEngineEnum +from easydiffraction.display.structure.viewing import Viewer +from easydiffraction.display.structure.viewing import ViewerFactory +from easydiffraction.project.categories.rendering_structure import default as rs_mod +from easydiffraction.project.categories.rendering_structure.default import AUTO_DESCRIPTION +from easydiffraction.project.categories.rendering_structure.default import AUTO_ENGINE +from easydiffraction.project.categories.rendering_structure.default import VIEW_ENGINE_OPTIONS +from easydiffraction.project.categories.rendering_structure.default import RenderingStructure +from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, +) +from easydiffraction.utils.logging import Logger + + +def _default_engine() -> str: + """Resolve the environment default engine deterministically.""" + return ViewerEngineEnum.default().value + + +# ---------------------------------------------------------------------- +# Module-level constants +# ---------------------------------------------------------------------- + + +class TestModuleConstants: + def test_auto_engine_constant(self): + assert AUTO_ENGINE == 'auto' + + def test_auto_description_is_nonempty_str(self): + assert isinstance(AUTO_DESCRIPTION, str) + assert AUTO_DESCRIPTION + + def test_view_engine_options_lists_auto_first(self): + assert VIEW_ENGINE_OPTIONS[0] == AUTO_ENGINE + + def test_view_engine_options_includes_every_enum_member(self): + for member in ViewerEngineEnum: + assert member.value in VIEW_ENGINE_OPTIONS + + def test_view_engine_options_has_no_extra_entries(self): + expected = {AUTO_ENGINE, *(m.value for m in ViewerEngineEnum)} + assert set(VIEW_ENGINE_OPTIONS) == expected + + +# ---------------------------------------------------------------------- +# Factory registration +# ---------------------------------------------------------------------- + + +class TestRenderingStructureFactory: + def test_supported_tags(self): + assert 'default' in RenderingStructureFactory.supported_tags() + + def test_default_tag(self): + assert RenderingStructureFactory.default_tag() == 'default' + + def test_create_returns_rendering_structure(self): + obj = RenderingStructureFactory.create('default') + assert isinstance(obj, RenderingStructure) + + +# ---------------------------------------------------------------------- +# Construction / defaults +# ---------------------------------------------------------------------- + + +class TestConstructionAndDefaults: + def test_type_info_tag(self): + assert RenderingStructure.type_info.tag == 'default' + + def test_category_class_attrs(self): + assert RenderingStructure._category_code == 'rendering_structure' + assert RenderingStructure._owner_attr_name == 'rendering_structure' + assert RenderingStructure._swap_method_name == '_swap_rendering_structure' + + def test_identity_category_code(self): + rs = RenderingStructure() + assert rs._identity.category_code == 'rendering_structure' + + def test_default_type_is_auto(self): + rs = RenderingStructure() + assert rs.type == AUTO_ENGINE + + def test_default_type_descriptor_value(self): + rs = RenderingStructure() + assert rs._type.value == AUTO_ENGINE + + def test_viewer_is_viewer_instance(self): + rs = RenderingStructure() + assert isinstance(rs.viewer, Viewer) + + def test_viewer_engine_is_environment_default(self): + rs = RenderingStructure() + assert rs.viewer.engine == _default_engine() + + def test_viewer_engine_is_supported(self): + rs = RenderingStructure() + assert rs.viewer.engine in ViewerFactory.supported_engines() + + def test_type_cif_handler_name(self): + rs = RenderingStructure() + assert rs._type._cif_handler.names == ['_rendering_structure.type'] + + def test_parent_starts_detached(self): + rs = RenderingStructure() + assert rs._parent is None + + +# ---------------------------------------------------------------------- +# viewer property +# ---------------------------------------------------------------------- + + +class TestViewerProperty: + def test_viewer_is_stable_reference(self): + rs = RenderingStructure() + assert rs.viewer is rs.viewer + + def test_distinct_instances_have_distinct_viewers(self): + first = RenderingStructure() + second = RenderingStructure() + # Viewer is constructed (not fetched as singleton) per category, + # so each category owns an independent facade. + assert first.viewer is not second.viewer + + +# ---------------------------------------------------------------------- +# _resolved_engine +# ---------------------------------------------------------------------- + + +class TestResolvedEngine: + def test_auto_resolves_to_environment_default(self): + assert RenderingStructure._resolved_engine(AUTO_ENGINE) == _default_engine() + + def test_explicit_ascii_passes_through(self): + assert RenderingStructure._resolved_engine('ascii') == 'ascii' + + def test_explicit_threejs_passes_through(self): + assert RenderingStructure._resolved_engine('threejs') == 'threejs' + + +# ---------------------------------------------------------------------- +# _set_type (validator: valid + invalid) +# ---------------------------------------------------------------------- + + +class TestSetType: + def test_set_explicit_engine_updates_type_and_viewer(self): + rs = RenderingStructure() + rs._set_type('threejs') + assert rs.type == 'threejs' + assert rs.viewer.engine == 'threejs' + + def test_set_ascii_updates_viewer_engine(self): + rs = RenderingStructure() + rs._set_type('ascii') + assert rs.type == 'ascii' + assert rs.viewer.engine == 'ascii' + + def test_set_auto_resolves_viewer_to_environment_default(self): + rs = RenderingStructure() + rs._set_type('threejs') + rs._set_type('auto') + assert rs.type == 'auto' + assert rs.viewer.engine == _default_engine() + + def test_invalid_type_strict_raises_value_error(self): + rs = RenderingStructure() + initial = rs.type + with pytest.raises(ValueError, match='Unsupported rendering_structure type'): + rs._set_type('bogus-engine') + assert rs.type == initial + + def test_invalid_type_strict_leaves_viewer_unchanged(self): + rs = RenderingStructure() + engine_before = rs.viewer.engine + with pytest.raises(ValueError, match='Unsupported rendering_structure type'): + rs._set_type('bogus-engine') + assert rs.viewer.engine == engine_before + + def test_invalid_type_non_strict_warns_and_keeps_value(self, monkeypatch): + rs = RenderingStructure() + warnings: list[str] = [] + monkeypatch.setattr(rs_mod.log, 'warning', warnings.append) + rs._set_type('bogus-engine', strict=False) + assert rs.type == AUTO_ENGINE + assert any('Unsupported rendering_structure type' in w for w in warnings) + + def test_strict_error_message_points_to_show_supported(self): + rs = RenderingStructure() + with pytest.raises(ValueError, match=r'rendering_structure\.show_supported'): + rs._set_type('nope') + + +# ---------------------------------------------------------------------- +# _supported_types +# ---------------------------------------------------------------------- + + +class TestSupportedTypes: + def test_includes_auto_pair_first(self): + pairs = RenderingStructure._supported_types({}) + assert pairs[0] == (AUTO_ENGINE, AUTO_DESCRIPTION) + + def test_lists_every_engine_from_factory(self): + pairs = RenderingStructure._supported_types({}) + tags = [tag for tag, _ in pairs] + for engine in ViewerFactory.supported_engines(): + assert engine in tags + + def test_descriptions_are_strings(self): + pairs = RenderingStructure._supported_types({}) + assert all(isinstance(desc, str) and desc for _, desc in pairs) + + def test_filters_argument_is_ignored(self): + with_filters = RenderingStructure._supported_types({'anything': object()}) + without_filters = RenderingStructure._supported_types({}) + assert with_filters == without_filters + + +# ---------------------------------------------------------------------- +# show_supported (inherited; lists enum values) +# ---------------------------------------------------------------------- + + +class TestShowSupported: + def test_runs_without_parent(self, capsys): + rs = RenderingStructure() + rs.show_supported() + out = capsys.readouterr().out + assert out # produced a table + + def test_lists_auto_and_every_engine(self, capsys): + rs = RenderingStructure() + rs.show_supported() + out = capsys.readouterr().out + assert AUTO_ENGINE in out + for member in ViewerEngineEnum: + assert member.value in out + + def test_marks_active_type(self, capsys): + rs = RenderingStructure() + rs._set_type('threejs') + rs.show_supported() + out = capsys.readouterr().out + assert '*' in out + assert 'threejs' in out + + +# ---------------------------------------------------------------------- +# type setter (SwitchableCategoryBase contract via parent) +# ---------------------------------------------------------------------- + + +class TestTypeSetter: + def test_detached_instance_raises_runtime_error(self): + rs = RenderingStructure() + with pytest.raises(RuntimeError, match='detached'): + rs.type = 'threejs' + + def test_setter_routes_through_owner_swap(self): + rs = RenderingStructure() + + class _Owner: + rendering_structure = rs + + @staticmethod + def _supported_filters_for(_category): + return {} + + def _swap_rendering_structure(self, new_type, *, strict=True): + rs._set_type(new_type, strict=strict) + + rs._parent = _Owner() + rs.type = 'threejs' + assert rs.type == 'threejs' + assert rs.viewer.engine == 'threejs' + + def test_setter_on_stale_instance_raises(self): + rs = RenderingStructure() + live = RenderingStructure() + + class _Owner: + # Owner's live category is a different instance. + rendering_structure = live + + def _swap_rendering_structure(self, new_type, *, strict=True): # pragma: no cover + live._set_type(new_type, strict=strict) + + rs._parent = _Owner() + with pytest.raises(RuntimeError, match='no longer the live category'): + rs.type = 'threejs' + + +# ---------------------------------------------------------------------- +# from_cif +# ---------------------------------------------------------------------- + + +class TestFromCif: + def _block(self, cif_text: str): + return gemmi.cif.read_string(cif_text).sole_block() + + def test_restores_type_via_parent_swap(self): + rs = RenderingStructure() + swapped: list[tuple[str, dict]] = [] + + class _Parent: + def _swap_rendering_structure(self, new_type, *, strict): + swapped.append((new_type, {'strict': strict})) + rs._set_type(new_type, strict=strict) + + rs._parent = _Parent() + block = self._block('data_test\n_rendering_structure.type threejs\n') + rs.from_cif(block) + assert swapped == [('threejs', {'strict': False})] + assert rs.type == 'threejs' + assert rs.viewer.engine == 'threejs' + + def test_tolerates_invalid_type(self, monkeypatch): + # The descriptor's own from_cif validation routes through the + # Logger; force WARN so it logs rather than raises (another test + # may have left the Logger in RAISE mode). + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + + rs = RenderingStructure() + + class _Parent: + def _swap_rendering_structure(self, new_type, *, strict): + rs._set_type(new_type, strict=strict) + + rs._parent = _Parent() + block = self._block('data_test\n_rendering_structure.type bogus-engine\n') + warnings: list[str] = [] + monkeypatch.setattr(rs_mod.log, 'warning', warnings.append) + rs.from_cif(block) + assert rs.type == AUTO_ENGINE + assert any('Unsupported rendering_structure type' in w for w in warnings) + + def test_missing_type_leaves_default(self): + rs = RenderingStructure() + called: list[str] = [] + + class _Parent: + def _swap_rendering_structure(self, new_type, *, strict): # pragma: no cover + called.append(new_type) + + rs._parent = _Parent() + block = self._block('data_test\n_other.value 1\n') + rs.from_cif(block) + assert rs.type == AUTO_ENGINE + assert called == [] + + +# ---------------------------------------------------------------------- +# as_cif (round-trip) +# ---------------------------------------------------------------------- + + +class TestAsCif: + def test_as_cif_contains_handler_name(self): + rs = RenderingStructure() + assert '_rendering_structure.type' in rs.as_cif + + def test_as_cif_reflects_explicit_engine(self): + rs = RenderingStructure() + rs._set_type('threejs') + assert 'threejs' in rs.as_cif + + def test_as_cif_round_trip_restores_type(self): + rs = RenderingStructure() + rs._set_type('threejs') + cif_text = rs.as_cif + + restored = RenderingStructure() + + class _Parent: + def _swap_rendering_structure(self, new_type, *, strict): + restored._set_type(new_type, strict=strict) + + restored._parent = _Parent() + block = gemmi.cif.read_string(f'data_test\n{cif_text}\n').sole_block() + restored.from_cif(block) + assert restored.type == 'threejs' diff --git a/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py new file mode 100644 index 000000000..40e0bb399 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/rendering_structure/test_factory.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the project rendering_structure factory.""" + +from __future__ import annotations + +import pytest + + +def test_module_import(): + import easydiffraction.project.categories.rendering_structure.factory as MUT + + expected_module_name = 'easydiffraction.project.categories.rendering_structure.factory' + assert MUT.__name__ == expected_module_name + + +def test_default_rules_universal_fallback(): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + # The factory declares a single universal-fallback rule. + assert RenderingStructureFactory._default_rules == {frozenset(): 'default'} + + +def test_supported_tags_lists_default(): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + tags = RenderingStructureFactory.supported_tags() + assert isinstance(tags, list) + assert 'default' in tags + + +def test_default_tag_without_conditions(): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + assert RenderingStructureFactory.default_tag() == 'default' + + +def test_default_tag_with_unmatched_conditions_falls_back(): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + # Extra conditions still match the empty-key universal fallback. + assert RenderingStructureFactory.default_tag(scattering_type='bragg') == 'default' + + +def test_create_returns_rendering_structure(): + from easydiffraction.project.categories.rendering_structure.default import RenderingStructure + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + rendering_structure = RenderingStructureFactory.create('default') + assert isinstance(rendering_structure, RenderingStructure) + + +def test_create_rejects_unknown_tag(): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + RenderingStructureFactory.create('missing') + + +def test_create_default_for_returns_rendering_structure(): + from easydiffraction.project.categories.rendering_structure.default import RenderingStructure + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + rendering_structure = RenderingStructureFactory.create_default_for() + assert isinstance(rendering_structure, RenderingStructure) + + +def test_supported_for_includes_registered_class(): + from easydiffraction.project.categories.rendering_structure.default import RenderingStructure + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + supported = RenderingStructureFactory.supported_for() + assert RenderingStructure in supported + + +def test_show_supported_lists_default(capsys): + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + RenderingStructureFactory.show_supported() + out = capsys.readouterr().out + assert 'Supported types' in out + assert 'default' in out + + +def test_registry_is_independent_from_base(): + from easydiffraction.core.factory import FactoryBase + from easydiffraction.project.categories.rendering_structure.default import RenderingStructure + from easydiffraction.project.categories.rendering_structure.factory import ( + RenderingStructureFactory, + ) + + # __init_subclass__ gives each factory its own registry; the + # registered concrete class must not leak onto the shared base. + assert RenderingStructure in RenderingStructureFactory._registry + assert RenderingStructure not in FactoryBase._registry diff --git a/tests/unit/easydiffraction/project/categories/table/test_default.py b/tests/unit/easydiffraction/project/categories/rendering_table/test_default.py similarity index 64% rename from tests/unit/easydiffraction/project/categories/table/test_default.py rename to tests/unit/easydiffraction/project/categories/rendering_table/test_default.py index 50dbccf8e..943b21d12 100644 --- a/tests/unit/easydiffraction/project/categories/table/test_default.py +++ b/tests/unit/easydiffraction/project/categories/rendering_table/test_default.py @@ -6,21 +6,21 @@ def test_table_defaults(): from easydiffraction.display.tables import TableEngineEnum - from easydiffraction.project.categories.table.default import Table + from easydiffraction.project.categories.rendering_table.default import RenderingTable - table = Table() + table = RenderingTable() assert table.type_info.tag == 'default' - assert table._identity.category_code == 'table' + assert table._identity.category_code == 'rendering_table' assert table.type == 'auto' assert table.tabler.engine in [member.value for member in TableEngineEnum] def test_table_selector_updates_engine(): from easydiffraction.display.tables import TableEngineEnum - from easydiffraction.project.categories.table.default import Table + from easydiffraction.project.categories.rendering_table.default import RenderingTable - table = Table() + table = RenderingTable() table._set_type('rich') @@ -34,20 +34,20 @@ def test_table_selector_updates_engine(): def test_table_from_cif_restores_type(): - from easydiffraction.project.categories.table.default import Table + from easydiffraction.project.categories.rendering_table.default import RenderingTable - table = Table() + table = RenderingTable() swapped: list[tuple[str, dict]] = [] class _Parent: - def _swap_table(self, new_type, *, strict): + def _swap_rendering_table(self, new_type, *, strict): swapped.append((new_type, {'strict': strict})) table._set_type(new_type, strict=strict) table._parent = _Parent() block = gemmi.cif.read_string( - 'data_test\n_table.type rich\n', + 'data_test\n_rendering_table.type rich\n', ).sole_block() table.from_cif(block) @@ -59,12 +59,12 @@ def _swap_table(self, new_type, *, strict): def test_table_invalid_type_assignment_raises(): import pytest - from easydiffraction.project.categories.table.default import Table + from easydiffraction.project.categories.rendering_table.default import RenderingTable - table = Table() + table = RenderingTable() initial_type = table.type - with pytest.raises(ValueError, match='Unsupported table type'): + with pytest.raises(ValueError, match='Unsupported rendering_table type'): table._set_type('bogus-engine') assert table.type == initial_type diff --git a/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py b/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py new file mode 100644 index 000000000..6624e40b7 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/rendering_table/test_factory.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_table_factory_default_and_create(): + from easydiffraction.project.categories.rendering_table.default import RenderingTable + from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory + + assert RenderingTableFactory.default_tag() == 'default' + assert 'default' in RenderingTableFactory.supported_tags() + + table = RenderingTableFactory.create('default') + + assert isinstance(table, RenderingTable) + + +def test_table_factory_rejects_unknown_tag(): + from easydiffraction.project.categories.rendering_table.factory import RenderingTableFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + RenderingTableFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/categories/structure_style/test_default.py b/tests/unit/easydiffraction/project/categories/structure_style/test_default.py new file mode 100644 index 000000000..a2b7d8190 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/structure_style/test_default.py @@ -0,0 +1,387 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the project structure_style category (default).""" + +from __future__ import annotations + +import gemmi +import pytest + + +def test_module_import(): + import easydiffraction.project.categories.structure_style.default as MUT + + expected_module_name = 'easydiffraction.project.categories.structure_style.default' + assert MUT.__name__ == expected_module_name + + +# ---------------------------------------------------------------------- +# Factory registration +# ---------------------------------------------------------------------- + + +class TestStructureStyleFactory: + def test_supported_tags(self): + from easydiffraction.project.categories.structure_style.factory import ( + StructureStyleFactory, + ) + + assert 'default' in StructureStyleFactory.supported_tags() + + def test_default_tag(self): + from easydiffraction.project.categories.structure_style.factory import ( + StructureStyleFactory, + ) + + assert StructureStyleFactory.default_tag() == 'default' + + def test_create_returns_structure_style(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.project.categories.structure_style.factory import ( + StructureStyleFactory, + ) + + obj = StructureStyleFactory.create('default') + assert isinstance(obj, StructureStyle) + + +# ---------------------------------------------------------------------- +# Identity, type_info and defaults +# ---------------------------------------------------------------------- + + +class TestStructureStyleIdentityAndDefaults: + def test_type_info(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + assert StructureStyle.type_info.tag == 'default' + assert StructureStyle.type_info.description != '' + + def test_category_code(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style._identity.category_code == 'structure_style' + + def test_instantiation(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style is not None + + def test_default_values(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style.atom_view.value == 'covalent' + assert style.color_scheme.value == 'jmol' + assert style.adp_probability.value == 0.99 + assert style.atom_scale.value == 0.3 + + def test_defaults_track_enum_defaults(self): + from easydiffraction.display.structure.enums import AtomViewEnum + from easydiffraction.display.structure.enums import ColorSchemeEnum + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style.atom_view.value == AtomViewEnum.default().value + assert style.color_scheme.value == ColorSchemeEnum.default().value + + def test_enum_descriptors_expose_backing_enum(self): + from easydiffraction.display.structure.enums import AtomViewEnum + from easydiffraction.display.structure.enums import ColorSchemeEnum + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style.atom_view.enum is AtomViewEnum + assert style.color_scheme.enum is ColorSchemeEnum + + def test_parameters_collects_all_descriptors(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + names = {param.name for param in style.parameters} + assert names == {'atom_view', 'color_scheme', 'adp_probability', 'atom_scale'} + + +# ---------------------------------------------------------------------- +# CIF handler names +# ---------------------------------------------------------------------- + + +class TestStructureStyleCifHandlerNames: + def test_cif_handler_names(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + assert style.atom_view._cif_handler.names == ['_structure_style.atom_view'] + assert style.color_scheme._cif_handler.names == ['_structure_style.color_scheme'] + assert style.adp_probability._cif_handler.names == ['_structure_style.adp_probability'] + assert style.atom_scale._cif_handler.names == ['_structure_style.atom_scale'] + + +# ---------------------------------------------------------------------- +# atom_view selector +# ---------------------------------------------------------------------- + + +class TestAtomViewSelector: + def test_setter_accepts_valid_values(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + for value in ('vdw', 'covalent', 'ionic', 'adp'): + style.atom_view = value + assert style.atom_view.value == value + + def test_setter_rejects_invalid_value(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + with pytest.raises(ValueError, match='not a valid AtomViewEnum'): + style.atom_view = 'bogus' + + def test_invalid_value_keeps_previous(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_view = 'vdw' + with pytest.raises(ValueError, match='not a valid AtomViewEnum'): + style.atom_view = 'bogus' + assert style.atom_view.value == 'vdw' + + def test_show_supported_lists_all_enum_values(self, capsys): + from easydiffraction.display.structure.enums import AtomViewEnum + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_view.show_supported() + + out = capsys.readouterr().out + for member in AtomViewEnum: + assert member.value in out + + def test_show_supported_marks_active_value(self, capsys): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_view = 'ionic' + style.atom_view.show_supported() + + out = capsys.readouterr().out + # Active value is annotated with an asterisk in its row. + marked_line = next(line for line in out.splitlines() if 'ionic' in line) + assert '*' in marked_line + + +# ---------------------------------------------------------------------- +# color_scheme selector +# ---------------------------------------------------------------------- + + +class TestColorSchemeSelector: + def test_setter_accepts_valid_values(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + for value in ('jmol', 'vesta'): + style.color_scheme = value + assert style.color_scheme.value == value + + def test_setter_rejects_invalid_value(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'): + style.color_scheme = 'bogus' + + def test_invalid_value_keeps_previous(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.color_scheme = 'vesta' + with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'): + style.color_scheme = 'bogus' + assert style.color_scheme.value == 'vesta' + + def test_show_supported_lists_all_enum_values(self, capsys): + from easydiffraction.display.structure.enums import ColorSchemeEnum + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.color_scheme.show_supported() + + out = capsys.readouterr().out + for member in ColorSchemeEnum: + assert member.value in out + + +# ---------------------------------------------------------------------- +# adp_probability numeric descriptor +# ---------------------------------------------------------------------- + + +class TestAdpProbability: + def test_setter_accepts_value_in_open_interval(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.adp_probability = 0.5 + assert style.adp_probability.value == 0.5 + + def test_out_of_range_raises_in_raise_mode(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + style = StructureStyle() + + with pytest.raises(TypeError, match='outside'): + style.adp_probability = 1.5 + + def test_boundaries_are_exclusive(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + style = StructureStyle() + + with pytest.raises(TypeError, match='outside'): + style.adp_probability = 0.0 + with pytest.raises(TypeError, match='outside'): + style.adp_probability = 1.0 + + def test_out_of_range_keeps_current_in_warn_mode(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + style = StructureStyle() + + style.adp_probability = 1.5 # rejected, current value kept + assert style.adp_probability.value == 0.99 + + def test_wrong_type_raises_in_raise_mode(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + style = StructureStyle() + + with pytest.raises(TypeError, match='Type mismatch'): + style.adp_probability = 'not-a-number' + + +# ---------------------------------------------------------------------- +# atom_scale numeric descriptor +# ---------------------------------------------------------------------- + + +class TestAtomScale: + def test_setter_accepts_value_in_range(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_scale = 0.8 + assert style.atom_scale.value == 0.8 + + def test_upper_boundary_is_inclusive(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_scale = 1.0 + assert style.atom_scale.value == 1.0 + + def test_lower_boundary_is_exclusive(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + style = StructureStyle() + + with pytest.raises(TypeError, match='outside'): + style.atom_scale = 0.0 + + def test_above_upper_boundary_raises_in_raise_mode(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + style = StructureStyle() + + with pytest.raises(TypeError, match='outside'): + style.atom_scale = 1.5 + + def test_out_of_range_keeps_current_in_warn_mode(self, monkeypatch): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.utils.logging import Logger + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + style = StructureStyle() + + style.atom_scale = 5.0 # rejected, current value kept + assert style.atom_scale.value == 0.3 + + +# ---------------------------------------------------------------------- +# CIF serialization / round-trip +# ---------------------------------------------------------------------- + + +class TestStructureStyleCif: + def test_as_cif_default_output(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + cif = style.as_cif + assert '_structure_style.atom_view covalent' in cif + assert '_structure_style.color_scheme jmol' in cif + assert '_structure_style.adp_probability 0.99' in cif + assert '_structure_style.atom_scale 0.3' in cif + + def test_as_cif_reflects_updated_values(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_view = 'vdw' + style.color_scheme = 'vesta' + + cif = style.as_cif + assert '_structure_style.atom_view vdw' in cif + assert '_structure_style.color_scheme vesta' in cif + + def test_from_cif_restores_all_fields(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + block = gemmi.cif.read_string( + 'data_test\n' + '_structure_style.atom_view vdw\n' + '_structure_style.color_scheme vesta\n' + '_structure_style.adp_probability 0.5\n' + '_structure_style.atom_scale 0.7\n', + ).sole_block() + + style.from_cif(block) + + assert style.atom_view.value == 'vdw' + assert style.color_scheme.value == 'vesta' + assert style.adp_probability.value == 0.5 + assert style.atom_scale.value == 0.7 + + def test_cif_round_trip_is_stable(self): + from easydiffraction.project.categories.structure_style.default import StructureStyle + + style = StructureStyle() + style.atom_view = 'covalent' + style.color_scheme = 'vesta' + style.adp_probability = 0.5 + style.atom_scale = 0.7 + + first_cif = style.as_cif + block = gemmi.cif.read_string(f'data_test\n{first_cif}\n').sole_block() + + restored = StructureStyle() + restored.from_cif(block) + + assert restored.as_cif == first_cif diff --git a/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py b/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py new file mode 100644 index 000000000..06b3195af --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/structure_style/test_factory.py @@ -0,0 +1,334 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the project structure_style factory.""" + +from __future__ import annotations + +import gemmi +import pytest + +from easydiffraction.utils.logging import Logger + + +@pytest.fixture +def raise_on_error(monkeypatch): + """Force Logger into RAISE mode for invalid-input assertions. + + Another test may have leaked WARN mode onto the shared Logger, + so validators that route through ``log.error()`` would silently + fall back instead of raising. Pin RAISE for the test body. + """ + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + + +# ---------------------------------------------------------------------- +# Module / factory surface +# ---------------------------------------------------------------------- + + +def test_module_import(): + import easydiffraction.project.categories.structure_style.factory as MUT + + expected_module_name = 'easydiffraction.project.categories.structure_style.factory' + assert MUT.__name__ == expected_module_name + + +def test_default_rules_universal_fallback(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + # The factory declares a single universal-fallback rule. + assert StructureStyleFactory._default_rules == {frozenset(): 'default'} + + +def test_supported_tags_lists_default(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + tags = StructureStyleFactory.supported_tags() + assert isinstance(tags, list) + assert tags == ['default'] + + +def test_default_tag_without_conditions(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + assert StructureStyleFactory.default_tag() == 'default' + + +def test_default_tag_with_unmatched_conditions_falls_back(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + # Extra conditions still match the empty-key universal fallback. + assert StructureStyleFactory.default_tag(scattering_type='bragg') == 'default' + + +def test_create_returns_structure_style(): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + structure_style = StructureStyleFactory.create('default') + assert isinstance(structure_style, StructureStyle) + + +def test_create_rejects_unknown_tag(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + StructureStyleFactory.create('missing') + + +def test_create_default_for_returns_structure_style(): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + structure_style = StructureStyleFactory.create_default_for() + assert isinstance(structure_style, StructureStyle) + + +def test_supported_for_includes_registered_class(): + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + supported = StructureStyleFactory.supported_for() + assert StructureStyle in supported + + +def test_show_supported_lists_default(capsys): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + StructureStyleFactory.show_supported() + out = capsys.readouterr().out + assert 'Supported types' in out + assert 'default' in out + + +def test_registry_is_independent_from_base(): + from easydiffraction.core.factory import FactoryBase + from easydiffraction.project.categories.structure_style.default import StructureStyle + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + # __init_subclass__ gives each factory its own registry; the + # registered concrete class must not leak onto the shared base. + assert StructureStyle in StructureStyleFactory._registry + assert StructureStyle not in FactoryBase._registry + + +# ---------------------------------------------------------------------- +# Created StructureStyle instance: identity, defaults, CIF handlers +# ---------------------------------------------------------------------- + + +def _make_style(): + from easydiffraction.project.categories.structure_style.factory import StructureStyleFactory + + return StructureStyleFactory.create('default') + + +def test_created_instance_identity(): + structure_style = _make_style() + + assert structure_style.type_info.tag == 'default' + assert structure_style._category_code == 'structure_style' + + +def test_created_instance_defaults(): + structure_style = _make_style() + + assert structure_style.atom_view.value == 'covalent' + assert structure_style.color_scheme.value == 'jmol' + assert structure_style.adp_probability.value == 0.99 + assert structure_style.atom_scale.value == 0.3 + + +def test_cif_handler_names(): + structure_style = _make_style() + + assert structure_style.atom_view._cif_handler.names == ['_structure_style.atom_view'] + assert structure_style.color_scheme._cif_handler.names == ['_structure_style.color_scheme'] + assert structure_style.adp_probability._cif_handler.names == [ + '_structure_style.adp_probability', + ] + assert structure_style.atom_scale._cif_handler.names == ['_structure_style.atom_scale'] + + +# ---------------------------------------------------------------------- +# Enum-backed selectors: valid setters and show_supported() +# ---------------------------------------------------------------------- + + +def test_atom_view_enum_backing(): + from easydiffraction.display.structure.enums import AtomViewEnum + + structure_style = _make_style() + + assert structure_style.atom_view.enum is AtomViewEnum + + +def test_color_scheme_enum_backing(): + from easydiffraction.display.structure.enums import ColorSchemeEnum + + structure_style = _make_style() + + assert structure_style.color_scheme.enum is ColorSchemeEnum + + +def test_atom_view_setter_accepts_each_member(): + from easydiffraction.display.structure.enums import AtomViewEnum + + structure_style = _make_style() + for member in AtomViewEnum: + structure_style.atom_view = member.value + assert structure_style.atom_view.value == member.value + + +def test_color_scheme_setter_accepts_each_member(): + from easydiffraction.display.structure.enums import ColorSchemeEnum + + structure_style = _make_style() + for member in ColorSchemeEnum: + structure_style.color_scheme = member.value + assert structure_style.color_scheme.value == member.value + + +def test_atom_view_setter_rejects_unknown_value(): + structure_style = _make_style() + + # The setter wraps the value in the enum constructor, which rejects + # unknown strings with ValueError before the descriptor is touched. + with pytest.raises(ValueError, match='not a valid AtomViewEnum'): + structure_style.atom_view = 'nope' + + +def test_color_scheme_setter_rejects_unknown_value(): + structure_style = _make_style() + + with pytest.raises(ValueError, match='not a valid ColorSchemeEnum'): + structure_style.color_scheme = 'nope' + + +def test_atom_view_show_supported_lists_all_members(capsys): + from easydiffraction.display.structure.enums import AtomViewEnum + + structure_style = _make_style() + structure_style.atom_view.show_supported() + out = capsys.readouterr().out + + assert 'Atom View types' in out + for member in AtomViewEnum: + assert member.value in out + # The active (default) value is marked. + assert '*' in out + + +def test_color_scheme_show_supported_lists_all_members(capsys): + from easydiffraction.display.structure.enums import ColorSchemeEnum + + structure_style = _make_style() + structure_style.color_scheme.show_supported() + out = capsys.readouterr().out + + assert 'Color Scheme types' in out + for member in ColorSchemeEnum: + assert member.value in out + assert '*' in out + + +# ---------------------------------------------------------------------- +# Numeric selectors: range validation (valid + invalid) +# ---------------------------------------------------------------------- + + +def test_adp_probability_setter_accepts_in_range(): + structure_style = _make_style() + + structure_style.adp_probability = 0.5 + assert structure_style.adp_probability.value == 0.5 + + +def test_atom_scale_setter_accepts_in_range(): + structure_style = _make_style() + + structure_style.atom_scale = 1.0 # le=1.0 boundary is allowed + assert structure_style.atom_scale.value == 1.0 + + +def test_adp_probability_rejects_value_at_or_above_one(raise_on_error): + structure_style = _make_style() + + with pytest.raises(TypeError, match=r'structure_style\.adp_probability'): + structure_style.adp_probability = 1.0 + + +def test_adp_probability_rejects_value_at_or_below_zero(raise_on_error): + structure_style = _make_style() + + with pytest.raises(TypeError, match=r'structure_style\.adp_probability'): + structure_style.adp_probability = 0.0 + + +def test_atom_scale_rejects_value_above_one(raise_on_error): + structure_style = _make_style() + + with pytest.raises(TypeError, match=r'structure_style\.atom_scale'): + structure_style.atom_scale = 2.0 + + +def test_atom_scale_rejects_value_at_or_below_zero(raise_on_error): + structure_style = _make_style() + + with pytest.raises(TypeError, match=r'structure_style\.atom_scale'): + structure_style.atom_scale = 0.0 + + +# ---------------------------------------------------------------------- +# CIF serialisation round-trip +# ---------------------------------------------------------------------- + + +def test_as_cif_emits_all_handlers(): + structure_style = _make_style() + structure_style.atom_view = 'vdw' + structure_style.color_scheme = 'vesta' + structure_style.adp_probability = 0.5 + structure_style.atom_scale = 0.8 + + cif = structure_style.as_cif + + assert '_structure_style.atom_view vdw' in cif + assert '_structure_style.color_scheme vesta' in cif + assert '_structure_style.adp_probability 0.5' in cif + assert '_structure_style.atom_scale 0.8' in cif + + +def test_from_cif_restores_all_fields(): + structure_style = _make_style() + block = gemmi.cif.read_string( + 'data_t\n' + '_structure_style.atom_view covalent\n' + '_structure_style.color_scheme vesta\n' + '_structure_style.adp_probability 0.5\n' + '_structure_style.atom_scale 0.8\n' + ).sole_block() + + structure_style.from_cif(block) + + assert structure_style.atom_view.value == 'covalent' + assert structure_style.color_scheme.value == 'vesta' + assert structure_style.adp_probability.value == 0.5 + assert structure_style.atom_scale.value == 0.8 + + +def test_cif_round_trip_preserves_values(): + structure_style = _make_style() + structure_style.atom_view = 'ionic' + structure_style.color_scheme = 'vesta' + structure_style.adp_probability = 0.75 + structure_style.atom_scale = 0.6 + + block = gemmi.cif.read_string(f'data_t\n{structure_style.as_cif}\n').sole_block() + restored = _make_style() + restored.from_cif(block) + + assert restored.atom_view.value == 'ionic' + assert restored.color_scheme.value == 'vesta' + assert restored.adp_probability.value == 0.75 + assert restored.atom_scale.value == 0.6 diff --git a/tests/unit/easydiffraction/project/categories/structure_view/test_default.py b/tests/unit/easydiffraction/project/categories/structure_view/test_default.py new file mode 100644 index 000000000..0655ebc28 --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/structure_view/test_default.py @@ -0,0 +1,359 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the project structure_view default category.""" + +from __future__ import annotations + +import pytest + +from easydiffraction.core.variable import BoolDescriptor +from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.project.categories.structure_view.default import StructureView +from easydiffraction.utils.logging import Logger + + +@pytest.fixture +def view() -> StructureView: + """Return a freshly constructed StructureView.""" + return StructureView() + + +@pytest.fixture +def raise_mode(monkeypatch) -> None: + """Force the shared Logger into RAISE mode for this test. + + Another test may have leaked WARN mode into the process-global + Logger, so validation-failure tests pin RAISE explicitly. + """ + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + + +# ---------------------------------------------------------------------- +# Module / class identity +# ---------------------------------------------------------------------- + + +def test_module_import(): + import easydiffraction.project.categories.structure_view.default as MUT + + expected_module_name = 'easydiffraction.project.categories.structure_view.default' + assert MUT.__name__ == expected_module_name + + +def test_type_info_tag(): + assert StructureView.type_info.tag == 'default' + + +def test_type_info_description(): + assert StructureView.type_info.description == 'Project structure_view category' + + +def test_category_code_class_attr(): + assert StructureView._category_code == 'structure_view' + + +def test_instantiation(view): + assert view is not None + + +def test_identity_category_code(view): + assert view._identity.category_code == 'structure_view' + + +def test_registered_with_factory(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + # The @StructureViewFactory.register decorator in default.py must + # register the concrete class under its type_info tag. + assert StructureView in StructureViewFactory._registry + + +# ---------------------------------------------------------------------- +# Descriptor types +# ---------------------------------------------------------------------- + + +def test_boolean_descriptor_types(view): + assert isinstance(view.show_labels, BoolDescriptor) + assert isinstance(view.show_moments, BoolDescriptor) + + +def test_range_descriptor_types(view): + for descriptor in ( + view.range_a_min, + view.range_a_max, + view.range_b_min, + view.range_b_max, + view.range_c_min, + view.range_c_max, + ): + assert isinstance(descriptor, NumericDescriptor) + + +# ---------------------------------------------------------------------- +# Defaults +# ---------------------------------------------------------------------- + + +def test_default_show_labels(view): + assert view.show_labels.value is False + + +def test_default_show_moments(view): + assert view.show_moments.value is True + + +def test_default_range_minimums(view): + assert view.range_a_min.value == 0.0 + assert view.range_b_min.value == 0.0 + assert view.range_c_min.value == 0.0 + + +def test_default_range_maximums(view): + assert view.range_a_max.value == 1.0 + assert view.range_b_max.value == 1.0 + assert view.range_c_max.value == 1.0 + + +# ---------------------------------------------------------------------- +# CIF handler names +# ---------------------------------------------------------------------- + + +def test_boolean_cif_handler_names(view): + assert view.show_labels._cif_handler.names == ['_structure_view.show_labels'] + assert view.show_moments._cif_handler.names == ['_structure_view.show_moments'] + + +def test_range_cif_handler_names(view): + expected = { + 'range_a_min': ['_structure_view.range_a_min'], + 'range_a_max': ['_structure_view.range_a_max'], + 'range_b_min': ['_structure_view.range_b_min'], + 'range_b_max': ['_structure_view.range_b_max'], + 'range_c_min': ['_structure_view.range_c_min'], + 'range_c_max': ['_structure_view.range_c_max'], + } + for attr, names in expected.items(): + assert getattr(view, attr)._cif_handler.names == names + + +def test_descriptor_names_match_attribute(view): + assert view.show_labels.name == 'show_labels' + assert view.show_moments.name == 'show_moments' + assert view.range_a_min.name == 'range_a_min' + assert view.range_c_max.name == 'range_c_max' + + +# ---------------------------------------------------------------------- +# Boolean setters +# ---------------------------------------------------------------------- + + +def test_show_labels_setter(view): + view.show_labels = True + assert view.show_labels.value is True + + +def test_show_moments_setter(view): + view.show_moments = False + assert view.show_moments.value is False + + +def test_show_labels_setter_rejects_non_bool(view, raise_mode): + with pytest.raises(TypeError): + view.show_labels = 'nope' + + +def test_show_moments_setter_rejects_non_bool(view, raise_mode): + with pytest.raises(TypeError): + view.show_moments = 'yes' + + +def test_show_labels_setter_keeps_current_in_warn_mode(view, monkeypatch): + # In WARN mode a bad-type assignment logs and keeps the current + # value rather than raising. + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + view.show_labels = 'nope' + assert view.show_labels.value is False + + +# ---------------------------------------------------------------------- +# Range setters — valid values +# ---------------------------------------------------------------------- + + +def test_range_a_setters_valid(view): + view.range_a_min = 0.25 + view.range_a_max = 0.75 + assert view.range_a_min.value == 0.25 + assert view.range_a_max.value == 0.75 + + +def test_range_b_setters_valid(view): + view.range_b_min = 0.1 + view.range_b_max = 0.9 + assert view.range_b_min.value == 0.1 + assert view.range_b_max.value == 0.9 + + +def test_range_c_setters_valid(view): + view.range_c_min = 0.2 + view.range_c_max = 0.8 + assert view.range_c_min.value == 0.2 + assert view.range_c_max.value == 0.8 + + +# ---------------------------------------------------------------------- +# Range setters — ordering guard (min < max, strict) +# ---------------------------------------------------------------------- + + +def test_range_min_above_max_is_ignored(view, raise_mode): + # Default window is (0.0, 1.0); a min of 0.9 still satisfies + # min < max, so set max low first to create the violation. + view.range_a_max = 0.3 + # 0.5 is not < 0.3, so the assignment must be rejected and the + # ordering guard must NOT raise (it only warns). + view.range_a_min = 0.5 + assert view.range_a_min.value == 0.0 + + +def test_range_max_below_min_is_ignored(view, raise_mode): + view.range_a_min = 0.6 + # 0.3 is not > 0.6, so the assignment is rejected; value unchanged. + view.range_a_max = 0.3 + assert view.range_a_max.value == 1.0 + + +def test_range_equal_bounds_are_ignored(view, raise_mode): + # Strict inequality: min == max must be rejected. + view.range_a_min = 0.4 + view.range_a_max = 0.4 + assert view.range_a_max.value == 1.0 + + +def test_ordering_guard_does_not_raise_even_in_raise_mode(view, raise_mode): + # The ordering guard logs a warning without exc_type, so it must + # never raise regardless of the Logger reaction mode. + view.range_b_max = 0.2 + view.range_b_min = 0.9 # rejected, but no exception + assert view.range_b_min.value == 0.0 + + +def test_ordering_guard_warns(view, monkeypatch, capsys): + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + view.range_c_max = 0.2 + view.range_c_min = 0.9 + out = capsys.readouterr().out + assert 'range_c_min' in out + assert 'min < max' in out + + +# ---------------------------------------------------------------------- +# Range setters — non-numeric input +# ---------------------------------------------------------------------- + + +def test_range_setter_rejects_non_numeric(view, raise_mode): + # A non-numeric value fails the ``lower < value < upper`` comparison + # inside the guard, raising a plain TypeError. + with pytest.raises(TypeError): + view.range_a_min = 'oops' + + +def test_range_setter_rejects_non_numeric_in_warn_mode(view, monkeypatch): + # The comparison TypeError is raised by Python itself, so it + # surfaces irrespective of the Logger reaction mode. + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + with pytest.raises(TypeError): + view.range_b_max = 'oops' + + +# ---------------------------------------------------------------------- +# view_range() +# ---------------------------------------------------------------------- + + +def test_view_range_defaults(view): + assert view.view_range() == ( + (0.0, 1.0), + (0.0, 1.0), + (0.0, 1.0), + ) + + +def test_view_range_reflects_updates(view): + view.range_a_min = 0.1 + view.range_a_max = 0.6 + view.range_b_min = 0.2 + view.range_b_max = 0.7 + view.range_c_min = 0.3 + view.range_c_max = 0.8 + assert view.view_range() == ( + (0.1, 0.6), + (0.2, 0.7), + (0.3, 0.8), + ) + + +def test_view_range_is_per_axis_min_max(view): + view.range_b_min = 0.25 + view.range_b_max = 0.75 + axis_a, axis_b, axis_c = view.view_range() + assert axis_a == (0.0, 1.0) + assert axis_b == (0.25, 0.75) + assert axis_c == (0.0, 1.0) + + +# ---------------------------------------------------------------------- +# as_cif +# ---------------------------------------------------------------------- + + +def test_as_cif_returns_str(view): + assert isinstance(view.as_cif, str) + + +def test_as_cif_contains_all_handlers(view): + cif = view.as_cif + for name in ( + '_structure_view.show_labels', + '_structure_view.show_moments', + '_structure_view.range_a_min', + '_structure_view.range_a_max', + '_structure_view.range_b_min', + '_structure_view.range_b_max', + '_structure_view.range_c_min', + '_structure_view.range_c_max', + ): + assert name in cif + + +def test_as_cif_reflects_boolean_values(view): + view.show_labels = True + view.show_moments = False + cif = view.as_cif + assert '_structure_view.show_labels true' in cif + assert '_structure_view.show_moments false' in cif + + +# ---------------------------------------------------------------------- +# parameters collection +# ---------------------------------------------------------------------- + + +def test_parameters_lists_all_descriptors(view): + names = {param.name for param in view.parameters} + assert names == { + 'show_labels', + 'show_moments', + 'range_a_min', + 'range_a_max', + 'range_b_min', + 'range_b_max', + 'range_c_min', + 'range_c_max', + } diff --git a/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py b/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py new file mode 100644 index 000000000..aca39d25f --- /dev/null +++ b/tests/unit/easydiffraction/project/categories/structure_view/test_factory.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the project structure_view factory.""" + +from __future__ import annotations + +import pytest + + +def test_module_import(): + import easydiffraction.project.categories.structure_view.factory as MUT + + expected_module_name = 'easydiffraction.project.categories.structure_view.factory' + assert MUT.__name__ == expected_module_name + + +def test_default_rules_universal_fallback(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + # The factory declares a single universal-fallback rule. + assert StructureViewFactory._default_rules == {frozenset(): 'default'} + + +def test_supported_tags_lists_default(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + tags = StructureViewFactory.supported_tags() + assert isinstance(tags, list) + assert 'default' in tags + + +def test_default_tag_without_conditions(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + assert StructureViewFactory.default_tag() == 'default' + + +def test_default_tag_with_unmatched_conditions_falls_back(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + # Extra conditions still match the empty-key universal fallback. + assert StructureViewFactory.default_tag(scattering_type='bragg') == 'default' + + +def test_create_returns_structure_view(): + from easydiffraction.project.categories.structure_view.default import StructureView + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + structure_view = StructureViewFactory.create('default') + assert isinstance(structure_view, StructureView) + + +def test_create_rejects_unknown_tag(): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): + StructureViewFactory.create('missing') + + +def test_create_default_for_returns_structure_view(): + from easydiffraction.project.categories.structure_view.default import StructureView + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + structure_view = StructureViewFactory.create_default_for() + assert isinstance(structure_view, StructureView) + + +def test_supported_for_includes_registered_class(): + from easydiffraction.project.categories.structure_view.default import StructureView + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + supported = StructureViewFactory.supported_for() + assert StructureView in supported + + +def test_show_supported_lists_default(capsys): + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + StructureViewFactory.show_supported() + out = capsys.readouterr().out + assert 'Supported types' in out + assert 'default' in out + + +def test_registry_is_independent_from_base(): + from easydiffraction.core.factory import FactoryBase + from easydiffraction.project.categories.structure_view.default import StructureView + from easydiffraction.project.categories.structure_view.factory import ( + StructureViewFactory, + ) + + # __init_subclass__ gives each factory its own registry; the + # registered concrete class must not leak onto the shared base. + assert StructureView in StructureViewFactory._registry + assert StructureView not in FactoryBase._registry diff --git a/tests/unit/easydiffraction/project/categories/table/test_factory.py b/tests/unit/easydiffraction/project/categories/table/test_factory.py deleted file mode 100644 index 72c9baa95..000000000 --- a/tests/unit/easydiffraction/project/categories/table/test_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import pytest - - -def test_table_factory_default_and_create(): - from easydiffraction.project.categories.table.default import Table - from easydiffraction.project.categories.table.factory import TableFactory - - assert TableFactory.default_tag() == 'default' - assert 'default' in TableFactory.supported_tags() - - table = TableFactory.create('default') - - assert isinstance(table, Table) - - -def test_table_factory_rejects_unknown_tag(): - from easydiffraction.project.categories.table.factory import TableFactory - - with pytest.raises(ValueError, match=r"Unsupported type: 'missing'"): - TableFactory.create('missing') diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index f80d243e4..45d30efee 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -11,8 +11,11 @@ from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum +from easydiffraction.datablocks.structure.item.base import Structure from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING from easydiffraction.display.plotting import _MeasVsCalcPlotOptions +from easydiffraction.display.structure.builder import FeatureAvailability +from easydiffraction.project.categories.structure_style.default import StructureStyle from easydiffraction.project.display import PatternOptionStatus from easydiffraction.project.display import ProjectDisplay from easydiffraction.utils.enums import VerbosityEnum @@ -63,7 +66,7 @@ def _recorder(*args, **kwargs): bayesian_predictive_datasets=[], _persisted_fit_state_sidecar={}, ), - chart=SimpleNamespace(plotter=plotter), + rendering_plot=SimpleNamespace(plotter=plotter), experiments={'hrpt': SimpleNamespace(type=SimpleNamespace())}, free_parameters=[], verbosity=SimpleNamespace(fit=SimpleNamespace(value='full')), @@ -71,6 +74,24 @@ def _recorder(*args, **kwargs): return project, calls +def _make_structure_display_project(structure: object) -> SimpleNamespace: + return SimpleNamespace( + structures={'lbco': structure}, + structure_style=StructureStyle(), + structure_view=SimpleNamespace( + view_range=lambda: ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)), + show_labels=SimpleNamespace(value=False), + show_moments=SimpleNamespace(value=False), + ), + rendering_structure=SimpleNamespace( + viewer=SimpleNamespace( + render=lambda scene, *, features: '', + supported_features=lambda: frozenset({'atoms', 'bonds', 'cell', 'axes'}), + ), + ), + ) + + def _make_statuses( *, measured: bool = False, @@ -301,8 +322,8 @@ def test_posterior_predictive_skips_processing_indicator_for_restored_cache(monk }, ) project.experiments = {'hrpt': SimpleNamespace(type=SimpleNamespace())} - project.chart.plotter.engine = 'plotly' - project.chart.plotter._resolve_x_axis = lambda expt_type, x: ( + project.rendering_plot.plotter.engine = 'plotly' + project.rendering_plot.plotter._resolve_x_axis = lambda expt_type, x: ( 'two_theta', 'two_theta', None, @@ -341,7 +362,7 @@ def fake_activity_indicator(label, *, verbosity): def test_posterior_distribution_without_param_plots_all_free_parameters(): project, calls = _make_project_stub() project.free_parameters = ['a', 'b'] - project.chart.plotter.engine = 'plotly' + project.rendering_plot.plotter.engine = 'plotly' display = ProjectDisplay(project) display.posterior.distribution() @@ -355,7 +376,7 @@ def test_posterior_distribution_without_param_plots_all_free_parameters(): def test_posterior_distribution_without_param_plots_all_free_parameters_for_ascii(): project, calls = _make_project_stub() project.free_parameters = ['a', 'b'] - project.chart.plotter.engine = 'asciichartpy' + project.rendering_plot.plotter.engine = 'asciichartpy' display = ProjectDisplay(project) display.posterior.distribution() @@ -546,7 +567,7 @@ def test_pattern_option_statuses_ignore_placeholder_arrays_without_usable_state( experiments={'hrpt': experiment}, structures=SimpleNamespace(names=['phase-a']), analysis=SimpleNamespace(fit_results=None), - chart=SimpleNamespace( + rendering_plot=SimpleNamespace( plotter=SimpleNamespace(_update_project_categories=lambda expt_name: None), type='plotly', ), @@ -592,7 +613,7 @@ def _recorder(*args, **kwargs): experiments={'heidi': experiment}, structures=SimpleNamespace(names=['si']), analysis=SimpleNamespace(fit_results=None), - chart=SimpleNamespace( + rendering_plot=SimpleNamespace( plotter=SimpleNamespace( _update_project_categories=lambda expt_name: None, _plot_meas_vs_calc_request=record('_plot_meas_vs_calc_request'), @@ -663,3 +684,94 @@ def fake_render_table(*, columns_headers, columns_alignment, columns_data): assert captured['columns_alignment'] == ['left', 'left', 'center', 'center', 'left'] assert captured['columns_data'][0][0] == 'auto' assert captured['columns_data'][1][0] == 'measured' + + +def test_structure_updates_categories_before_building_scene(monkeypatch, tmp_path): + structure = Structure(name='lbco') + structure.space_group.name_h_m = 'P m -3 m' + structure.cell.length_a = 3.88 + assert structure.cell.length_b.value == 10.0 + + project = _make_structure_display_project(structure) + display = ProjectDisplay(project) + captured: dict[str, object] = {} + + def fake_build_scene(structure_arg, *, style, view_range, features): + captured['cell_lengths'] = ( + structure_arg.cell.length_a.value, + structure_arg.cell.length_b.value, + structure_arg.cell.length_c.value, + ) + captured['style'] = style + captured['view_range'] = view_range + captured['features'] = features + return SimpleNamespace() + + monkeypatch.setattr( + 'easydiffraction.display.structure.builder.build_scene', + fake_build_scene, + ) + + display.structure('lbco', path=str(tmp_path / 'lbco.html')) + + assert captured['cell_lengths'] == pytest.approx((3.88, 3.88, 3.88)) + assert captured['style'] is project.structure_style + assert captured['view_range'] == ((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)) + assert captured['features'] == frozenset({'cell', 'axes'}) + + +def test_show_structure_options_updates_categories_before_availability(monkeypatch): + calls: list[str] = [] + structure = SimpleNamespace(updated=False) + + def update_categories(): + calls.append('update') + structure.updated = True + + def fake_structure_feature_availability(structure_arg, *, style): + calls.append('availability') + assert structure_arg.updated is True + return FeatureAvailability(frozenset({'cell', 'axes'}), ()) + + structure._update_categories = update_categories + project = _make_structure_display_project(structure) + display = ProjectDisplay(project) + + monkeypatch.setattr( + 'easydiffraction.display.structure.builder.structure_feature_availability', + fake_structure_feature_availability, + ) + monkeypatch.setattr('easydiffraction.project.display.render_table', lambda **kwargs: None) + + display.show_structure_options('lbco') + + assert calls == ['update', 'availability'] + + +def test_show_structure_options_omits_reason_column(monkeypatch): + structure = Structure(name='lbco') + project = _make_structure_display_project(structure) + display = ProjectDisplay(project) + captured: dict[str, object] = {} + + def fake_structure_feature_availability(structure_arg, *, style): + assert structure_arg is structure + assert style is project.structure_style + return FeatureAvailability(frozenset({'cell', 'axes'}), ()) + + def fake_render_table(*, columns_headers, columns_alignment, columns_data): + captured['columns_headers'] = columns_headers + captured['columns_alignment'] = columns_alignment + captured['columns_data'] = columns_data + + monkeypatch.setattr( + 'easydiffraction.display.structure.builder.structure_feature_availability', + fake_structure_feature_availability, + ) + monkeypatch.setattr('easydiffraction.project.display.render_table', fake_render_table) + + display.show_structure_options('lbco') + + assert captured['columns_headers'] == ['Option', 'Description', 'Available', 'Auto'] + assert captured['columns_alignment'] == ['left', 'left', 'center', 'center'] + assert all(len(row) == 4 for row in captured['columns_data']) diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py index 153fd5687..92b336483 100644 --- a/tests/unit/easydiffraction/project/test_project.py +++ b/tests/unit/easydiffraction/project/test_project.py @@ -67,19 +67,20 @@ def test_project_free_params_aggregate_structures_and_experiments(): def test_project_exposes_chart_table_and_display_facades(): - from easydiffraction.project.categories.chart import Chart - from easydiffraction.project.categories.table import Table + from easydiffraction.project.categories.rendering_plot import RenderingPlot + from easydiffraction.project.categories.rendering_table import RenderingTable from easydiffraction.project.display import ProjectDisplay from easydiffraction.project.project import Project from easydiffraction.report import Report project = Project() - assert isinstance(project.chart, Chart) - assert isinstance(project.table, Table) + assert isinstance(project.rendering_plot, RenderingPlot) + assert isinstance(project.rendering_table, RenderingTable) assert isinstance(project.display, ProjectDisplay) assert isinstance(project.report, Report) assert hasattr(project.report, 'save') + assert 'publication' not in Project._public_attrs() def test_apply_params_from_csv_resolves_relative_file_paths(tmp_path): diff --git a/tests/unit/easydiffraction/project/test_project_config.py b/tests/unit/easydiffraction/project/test_project_config.py index c921b3199..6c9a4beeb 100644 --- a/tests/unit/easydiffraction/project/test_project_config.py +++ b/tests/unit/easydiffraction/project/test_project_config.py @@ -8,9 +8,9 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): from easydiffraction.core.category_owner import CategoryOwner - from easydiffraction.project.categories.chart import Chart + from easydiffraction.project.categories.rendering_plot import RenderingPlot from easydiffraction.project.categories.report import Report - from easydiffraction.project.categories.table import Table + from easydiffraction.project.categories.rendering_table import RenderingTable from easydiffraction.project.project_config import ProjectConfig from easydiffraction.project.project_info import ProjectInfo @@ -18,13 +18,13 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): assert isinstance(config, CategoryOwner) assert isinstance(config.info, ProjectInfo) - assert isinstance(config.chart, Chart) + assert isinstance(config.rendering_plot, RenderingPlot) assert isinstance(config.report, Report) - assert isinstance(config.table, Table) + assert isinstance(config.rendering_table, RenderingTable) assert config.info._parent is config - assert config.chart._parent is config + assert config.rendering_plot._parent is config assert config.report._parent is config - assert config.table._parent is config + assert config.rendering_table._parent is config assert config.info.name == 'beer' assert config.info.title == 'Beer title' assert config.info.description == 'Some description' @@ -35,17 +35,23 @@ def test_project_config_exposes_project_info_chart_and_table_categories(): assert config.verbosity.fit.value == 'full' assert config.categories == [ config.info, - config.chart, + config.rendering_plot, config.report, - config.table, + config.rendering_table, config.verbosity, + config.rendering_structure, + config.structure_view, + config.structure_style, ] assert config.parameters == ( config.info.parameters - + config.chart.parameters + + config.rendering_plot.parameters + config.report.parameters - + config.table.parameters + + config.rendering_table.parameters + config.verbosity.parameters + + config.rendering_structure.parameters + + config.structure_view.parameters + + config.structure_style.parameters ) @@ -62,16 +68,18 @@ def test_project_config_as_cif_has_project_chart_and_table_sections_without_data assert '_project.description' in cif_text assert '_project.created' in cif_text assert '_project.last_modified' in cif_text - assert '_chart.type' in cif_text + assert '_rendering_plot.type' in cif_text assert '_report.cif' in cif_text assert '_report.html' in cif_text assert '_report.tex' in cif_text assert '_report.pdf' in cif_text assert '_report.html_offline' in cif_text - assert '_table.type' in cif_text - assert '_chart.type auto' in cif_text - assert '_table.type auto' in cif_text + assert '_rendering_table.type' in cif_text + assert '_rendering_plot.type auto' in cif_text + assert '_rendering_table.type auto' in cif_text assert '_verbosity.fit full' in cif_text + assert '_journal.' not in cif_text + assert '_publ_' not in cif_text def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path): @@ -83,15 +91,17 @@ def test_project_save_and_load_use_auto_display_defaults_when_unset(tmp_path): project_cif = (tmp_path / 'proj' / 'project.cif').read_text() assert not project_cif.startswith('data_') - assert '_chart.type auto' in project_cif + assert '_rendering_plot.type auto' in project_cif assert '_report.cif false' in project_cif - assert '_table.type auto' in project_cif + assert '_rendering_table.type auto' in project_cif assert '_verbosity.fit full' in project_cif + assert '_journal.' not in project_cif + assert '_publ_' not in project_cif loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.chart.type == 'auto' - assert loaded.table.type == 'auto' + assert loaded.rendering_plot.type == 'auto' + assert loaded.rendering_table.type == 'auto' assert loaded.verbosity.fit.value == 'full' @@ -99,16 +109,16 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path): from easydiffraction.project.project import Project project = Project(name='beer', title='Beer title', description='Some description') - project.chart.type = 'asciichartpy' - project.table.type = 'rich' + project.rendering_plot.type = 'asciichartpy' + project.rendering_table.type = 'rich' project.save_as(str(tmp_path / 'proj')) project_cif = (tmp_path / 'proj' / 'project.cif').read_text() assert not project_cif.startswith('data_') assert '_project.id beer' in project_cif - assert '_chart.type asciichartpy' in project_cif + assert '_rendering_plot.type asciichartpy' in project_cif assert '_report.cif false' in project_cif - assert '_table.type rich' in project_cif + assert '_rendering_table.type rich' in project_cif assert '_verbosity.fit full' in project_cif loaded = Project.load(str(tmp_path / 'proj')) @@ -117,8 +127,8 @@ def test_project_save_and_load_keep_project_config_section_format(tmp_path): assert loaded.info.description == 'Some description' assert isinstance(loaded.info.created, datetime.datetime) assert isinstance(loaded.info.last_modified, datetime.datetime) - assert loaded.chart.type == 'asciichartpy' - assert loaded.table.type == 'rich' + assert loaded.rendering_plot.type == 'asciichartpy' + assert loaded.rendering_table.type == 'rich' assert loaded.verbosity.fit.value == 'full' diff --git a/tests/unit/easydiffraction/project/test_project_load.py b/tests/unit/easydiffraction/project/test_project_load.py index cfbd7652d..2a5c1b4a0 100644 --- a/tests/unit/easydiffraction/project/test_project_load.py +++ b/tests/unit/easydiffraction/project/test_project_load.py @@ -83,14 +83,18 @@ def test_round_trips_fit_mode(self, tmp_path): def test_round_trips_display_engine_configuration(self, tmp_path): original = Project(name='d1') - original.chart.type = 'asciichartpy' - original.table.type = 'rich' + original.rendering_plot.type = 'asciichartpy' + original.rendering_table.type = 'rich' + original.rendering_structure.type = 'ascii' + original.structure_style.atom_view = 'vdw' original.save_as(str(tmp_path / 'proj')) loaded = Project.load(str(tmp_path / 'proj')) - assert loaded.chart.type == 'asciichartpy' - assert loaded.table.type == 'rich' + assert loaded.rendering_plot.type == 'asciichartpy' + assert loaded.rendering_table.type == 'rich' + assert loaded.rendering_structure.type == 'ascii' + assert loaded.structure_style.atom_view.value == 'vdw' def test_round_trips_constraints(self, tmp_path): original = Project(name='c1') diff --git a/tests/unit/easydiffraction/project/test_publication_loader.py b/tests/unit/easydiffraction/project/test_publication_loader.py deleted file mode 100644 index 565c5afab..000000000 --- a/tests/unit/easydiffraction/project/test_publication_loader.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -import pytest - - -def test_load_publication_reads_toml_metadata(tmp_path): - from easydiffraction.project.categories.publication.default import Publication - from easydiffraction.project.publication_loader import load_publication - - path = tmp_path / 'publication.toml' - path.write_text( - """ -journal_name_full = "Journal of Testing" -body_title = "Refinement report" -body_keywords = ["diffraction", "neutron"] - -[[authors]] -name = "Ada Lovelace" -address = "London" -""".lstrip(), - encoding='utf-8', - ) - publication = Publication() - - load_publication(publication, path) - - assert publication.journal.name_full.value == 'Journal of Testing' - assert publication.body.title.value == 'Refinement report' - assert publication.body.keywords == ['diffraction', 'neutron'] - assert len(publication.authors) == 1 - assert publication.authors[0].name.value == 'Ada Lovelace' - assert publication.authors[0].address.value == 'London' - - -def test_load_publication_rejects_unknown_extension(tmp_path): - from easydiffraction.project.categories.publication.default import Publication - from easydiffraction.project.publication_loader import load_publication - - path = tmp_path / 'publication.txt' - path.write_text('body_title = "x"', encoding='utf-8') - - with pytest.raises(ValueError, match='Unsupported publication-info format'): - load_publication(Publication(), path) - - -def test_load_publication_rejects_invalid_author_shape(tmp_path): - from easydiffraction.project.categories.publication.default import Publication - from easydiffraction.project.publication_loader import load_publication - - path = tmp_path / 'publication.json' - path.write_text('{"authors": [{"address": "missing name"}]}', encoding='utf-8') - - with pytest.raises(ValueError, match=r'authors\[0\]\.name'): - load_publication(Publication(), path) diff --git a/tests/unit/easydiffraction/report/test_data_context.py b/tests/unit/easydiffraction/report/test_data_context.py index 8034e756f..ff34c0be8 100644 --- a/tests/unit/easydiffraction/report/test_data_context.py +++ b/tests/unit/easydiffraction/report/test_data_context.py @@ -141,7 +141,6 @@ def _project() -> SimpleNamespace: software=SimpleNamespace(), constraints=[], ), - publication=SimpleNamespace(), ) @@ -151,6 +150,7 @@ def test_report_data_context_builds_fit_data(): context = build_report_data_context(_project()) fit_data = context['experiments'][0]['fit_data'] + assert 'publication' not in context assert fit_data['axes_labels'] == ['I²calc', 'I²meas'] assert list(fit_data['series']['meas']['su']) == [0.5, 0.7] assert list(fit_data['series']['calc']['values']) == [10.0, 20.0] diff --git a/tests/unit/easydiffraction/report/test_fit_plot.py b/tests/unit/easydiffraction/report/test_fit_plot.py index c041f1948..0a231f1a8 100644 --- a/tests/unit/easydiffraction/report/test_fit_plot.py +++ b/tests/unit/easydiffraction/report/test_fit_plot.py @@ -45,3 +45,30 @@ def test_fit_plot_ranges_match_plotly_main_intensity_margin(): assert ranges['y_max'] == pytest.approx(31.25) assert ranges['residual_y_min'] == pytest.approx(-3.4375) assert ranges['residual_y_max'] == pytest.approx(3.4375) + + +def test_fit_scatter_ranges_share_one_range_and_add_tick_step(): + from easydiffraction.report.fit_plot import fit_scatter_ranges + + fit_data = { + 'x': {'values': [10.0, 90.0]}, + 'series': {'meas': {'values': [0.0, 80.0], 'su': [5.0, 5.0]}}, + } + + ranges = fit_scatter_ranges(fit_data) + + # x and y share one range (so the diagonal is a true y=x line), and it + # unions calc (10..90) with meas +/- su (-5..85), padded both ends. + assert ranges['x_min'] == ranges['y_min'] == ranges['diag_min'] + assert ranges['x_max'] == ranges['y_max'] == ranges['diag_max'] + assert ranges['x_min'] < -5.0 + assert ranges['x_max'] > 90.0 + assert ranges['tick_step'] > 0.0 + + +def test_fit_plot_axis_styles_expose_shared_diagonal_color(): + from easydiffraction.report.fit_plot import fit_plot_axis_styles + + styles = fit_plot_axis_styles() + + assert styles['diag_rgb'] == '190,199,208' diff --git a/tests/unit/easydiffraction/report/test_html_renderer.py b/tests/unit/easydiffraction/report/test_html_renderer.py index d07d6bd40..4a965055a 100644 --- a/tests/unit/easydiffraction/report/test_html_renderer.py +++ b/tests/unit/easydiffraction/report/test_html_renderer.py @@ -84,12 +84,6 @@ def _context() -> dict[str, object]: 'n_phases': 1, 'n_experiments': 0, }, - 'publication': { - 'body': {'title': '', 'abstract': '', 'synopsis': '', 'keywords': ''}, - 'authors': [], - 'journal': {'name_full': '', 'year': '', 'paper_doi': ''}, - 'contact_author': {'name': '', 'email': ''}, - }, 'metadata': { 'generated_at': '2026-05-26T00:00:00Z', 'easydiffraction_version': '0.0', @@ -355,6 +349,7 @@ def test_render_html_report_uses_plotly_fit_style_order(): html = render_html_report(context) + assert 'Diffraction pattern for experiment' in html measured = html.index('"name":"Measured (Imeas)"') background = html.index('"name":"Background (Ibkg)"') calculated = html.index('"name":"Total calculated (Icalc)"') diff --git a/tests/unit/easydiffraction/report/test_style.py b/tests/unit/easydiffraction/report/test_style.py index bd4f07ea2..826789c9c 100644 --- a/tests/unit/easydiffraction/report/test_style.py +++ b/tests/unit/easydiffraction/report/test_style.py @@ -11,7 +11,8 @@ def test_report_style_context_exposes_hex_and_rgb_values(): assert context['axis_hex'] == '#bec7d0' assert context['axis_rgb'] == '190,199,208' - assert context['grid_hex'] == '#d9dfe4' + assert context['grid_hex'] == '#e0e0e0' + assert context['grid_rgb'] == '224,224,224' assert context['chart_grid_rgb'] == '235,240,248' assert context['subtitle'] == 'EasyDiffraction Report' assert 'PT Sans' in context['html_font_family'] diff --git a/tests/unit/easydiffraction/report/test_tex_renderer.py b/tests/unit/easydiffraction/report/test_tex_renderer.py index 41eedca56..61efd39a7 100644 --- a/tests/unit/easydiffraction/report/test_tex_renderer.py +++ b/tests/unit/easydiffraction/report/test_tex_renderer.py @@ -16,24 +16,6 @@ def _minimal_context() -> dict[str, object]: 'n_phases': 0, 'n_experiments': 0, }, - 'publication': { - 'body': { - 'title': '', - 'abstract': '', - 'synopsis': '', - 'keywords': '', - }, - 'authors': [], - 'journal': { - 'name_full': '', - 'year': '', - 'paper_doi': '', - }, - 'contact_author': { - 'name': '', - 'email': '', - }, - }, 'metadata': { 'generated_at': '2026-05-26T00:00:00Z', 'easydiffraction_version': '0.0', @@ -143,7 +125,7 @@ def test_render_tex_report_renders_default_document(): tex = render_tex_report(context) assert r'\documentclass[11pt]{article}' in tex - assert r'\usepackage[margin=2.5cm]{geometry}' in tex + assert r'\usepackage[margin=2cm]{geometry}' in tex assert r'\usepackage{fourier}' in tex assert r'\usepackage{longtable}' in tex assert r'\usepackage{paratype}' in tex @@ -459,3 +441,36 @@ def test_save_tex_report_removes_stale_managed_bundle_dirs(tmp_path): assert not (tex_dir / 'data').exists() assert not (tex_dir / 'figures').exists() assert not (tex_dir / 'styles').exists() + + +def test_save_tex_report_writes_structure_figure_png(tmp_path): + import easydiffraction as ed + + from easydiffraction.report.tex_renderer import save_tex_report + + project = ed.Project(name='struct_fig') + project.structures.create(name='nacl') + structure = project.structures['nacl'] + structure.cell.length_a = 5.64 + structure.cell.length_b = 5.64 + structure.cell.length_c = 5.64 + structure.atom_sites.create( + label='Na', type_symbol='Na', fract_x=0, fract_y=0, fract_z=0, adp_iso=0.5, occupancy=1 + ) + structure.atom_sites.create( + label='Cl', + type_symbol='Cl', + fract_x=0.5, + fract_y=0.5, + fract_z=0.5, + adp_iso=0.5, + occupancy=1, + ) + + tex_path = tmp_path / 'report.tex' + save_tex_report(project, project.report.data_context(), path=tex_path) + + figure_path = tex_path.parent / 'data' / 'struct_nacl.png' + assert figure_path.exists() + assert figure_path.read_bytes().startswith(b'\x89PNG\r\n\x1a\n') + assert 'data/struct_nacl.png' in tex_path.read_text(encoding='utf-8') diff --git a/tools/tweak_notebooks.py b/tools/tweak_notebooks.py index 628305672..766c6431c 100644 --- a/tools/tweak_notebooks.py +++ b/tools/tweak_notebooks.py @@ -1,12 +1,16 @@ -"""Insert a bootstrap code cell as the first cell of every notebook. +"""Post-process generated tutorial notebooks. Usage:: python tools/tweak_notebooks.py tutorials/ [more_paths ...] -The bootstrap cell: +Inserts a bootstrap code cell as the first cell of every notebook. The +bootstrap cell: - Checks if ``easydiffraction`` is importable; if not, installs it. - Adds the tag ``hide-in-docs``. - Idempotent: skipped if already present and identical. + +Also sorts each notebook's jupytext ``cell_metadata_filter`` so its entry +order stays stable across runs and does not create noisy diffs. """ from __future__ import annotations @@ -94,7 +98,33 @@ def ensure_bootstrap(nb, bootstrap_source: str) -> bool: return True -def process_notebook(path: Path, bootstrap_source: str) -> int: +def normalize_cell_metadata_filter(nb) -> bool: + """Sort the jupytext ``cell_metadata_filter`` for a stable order. + + jupytext derives this filter from an unordered set, so multi-entry + values (e.g. ``title,tags``) can swap order between runs and produce + noisy diffs. Sort the positive entries alphabetically and keep any + ``-``-prefixed directives (e.g. ``-all``) last. + + Returns True if the stored value changed. + """ + jupytext_meta = nb.metadata.get('jupytext') + if not isinstance(jupytext_meta, dict): + return False + current = jupytext_meta.get('cell_metadata_filter') + if not isinstance(current, str): + return False + entries = [part.strip() for part in current.split(',') if part.strip()] + positives = sorted(entry for entry in entries if not entry.startswith('-')) + negatives = sorted(entry for entry in entries if entry.startswith('-')) + normalized = ','.join(positives + negatives) + if normalized == current: + return False + jupytext_meta['cell_metadata_filter'] = normalized + return True + + +def process_notebook(path: Path, bootstrap_source: str) -> list[str]: nb = nbformat.read(path, as_version=4) # Remove all 'tags' metadata from cells @@ -102,16 +132,17 @@ def process_notebook(path: Path, bootstrap_source: str) -> int: if 'tags' in cell.metadata: cell.metadata.pop('tags') - # Add the bootstrap cell if needed - changed = 0 + reasons: list[str] = [] if ensure_bootstrap(nb, bootstrap_source): - changed += 1 + reasons.append('inserted bootstrap cell') + if normalize_cell_metadata_filter(nb): + reasons.append('sorted cell_metadata_filter') # Normalize to ensure cell ids exist and structure is valid - if changed or any('id' not in c for c in nb.cells): + if reasons or any('id' not in c for c in nb.cells): normalize(nb) nbformat.write(nb, path) - return changed + return reasons def main(argv: list[str]) -> int: @@ -131,9 +162,9 @@ def main(argv: list[str]) -> int: updated = 0 for nb_path in targets: - changes = process_notebook(nb_path, bootstrap_source) - if changes: - print(f'UPDATED: {nb_path} (inserted bootstrap cell)') + reasons = process_notebook(nb_path, bootstrap_source) + if reasons: + print(f'UPDATED: {nb_path} ({"; ".join(reasons)})') updated += 1 if updated == 0: From a4366bb20b383674428c7c85c58a7a0eab97d4b5 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 2 Jun 2026 13:49:38 +0200 Subject: [PATCH 08/12] Complete the bundled space-group database (#187) * Add space-group-database ADR suggestion * Add space-group-database implementation plan * Add cctbx-based space-group table extraction * Add multi-source cross-check and disagreement report * Generate initial space-group disagreement report * Localize space-group curation artifacts * Move crysview demo to subdirectory * Add curated space-group overrides * Generate complete space_groups.json.gz with recorded provenance * Load space groups from JSON and drop restricted unpickler * Reach Phase 1 review gate * Cover public space-group coordinate aliases * Add space-group database ADR index row * Add Phase 2 space-group database tests and packaging check * Apply pixi run fix auto-fixes * Alias runtime coordinate codes in space-group database * Apply prettier formatting to space-group-database ADR * Make packaging check inspect the wheel directly * Unify Plotly tooltip styling and fix white Bragg hover * Guard optional IPython import in table backend base * Pin space-group record count in tests and wheel check * Update Plotly hover test and fix overlong comment * Update tutorial links to latest docs * Add Wyckoff letter detection ADR --- .../crysview-structure-visualization.md | 4 +- .../crysview-threejs-demo.html | 0 docs/dev/adrs/index.md | 83 +-- .../suggestions/background-auto-estimate.md | 532 ++++++++++++++++++ .../suggestions/plotting-docs-performance.md | 434 ++++++++++++++ .../adrs/suggestions/space-group-database.md | 515 +++++++++++++++++ .../space_groups_overrides.yaml | 11 + .../suggestions/wyckoff-letter-detection.md | 502 +++++++++++++++++ docs/dev/package-structure/full.md | 1 - docs/dev/plans/background-auto-estimate.md | 276 +++++++++ docs/dev/plans/space-group-database.md | 275 +++++++++ docs/docs/tutorials/ed-13.ipynb | 2 +- docs/docs/tutorials/ed-13.py | 2 +- .../crystallography/space_groups.json.gz | Bin 0 -> 117210 bytes .../crystallography/space_groups.pkl.gz | Bin 48378 -> 0 bytes .../crystallography/space_groups.py | 104 +--- .../display/plotters/plotly.py | 195 +++++-- src/easydiffraction/display/tablers/base.py | 9 +- .../crystallography/test_space_groups.py | 70 ++- .../test_space_groups_coverage.py | 154 ++--- .../display/plotters/test_plotly.py | 14 +- .../display/tablers/test_base.py | 11 + tools/check_packaged_db.py | 69 +++ 23 files changed, 3024 insertions(+), 239 deletions(-) rename docs/dev/adrs/accepted/{ => crysview-structure-visualization}/crysview-threejs-demo.html (100%) create mode 100644 docs/dev/adrs/suggestions/background-auto-estimate.md create mode 100644 docs/dev/adrs/suggestions/plotting-docs-performance.md create mode 100644 docs/dev/adrs/suggestions/space-group-database.md create mode 100644 docs/dev/adrs/suggestions/space-group-database/space_groups_overrides.yaml create mode 100644 docs/dev/adrs/suggestions/wyckoff-letter-detection.md create mode 100644 docs/dev/plans/background-auto-estimate.md create mode 100644 docs/dev/plans/space-group-database.md create mode 100644 src/easydiffraction/crystallography/space_groups.json.gz delete mode 100644 src/easydiffraction/crystallography/space_groups.pkl.gz create mode 100644 tools/check_packaged_db.py diff --git a/docs/dev/adrs/accepted/crysview-structure-visualization.md b/docs/dev/adrs/accepted/crysview-structure-visualization.md index c6dd4c154..b5fc2281a 100644 --- a/docs/dev/adrs/accepted/crysview-structure-visualization.md +++ b/docs/dev/adrs/accepted/crysview-structure-visualization.md @@ -22,8 +22,8 @@ parameters a refinement is adjusting. A working prototype establishes the target experience and the data it needs. It lives at -[`crysview-threejs-demo.html`](crysview-threejs-demo.html) and -demonstrates, against a non-orthogonal unit cell: +[`crysview-threejs-demo.html`](crysview-structure-visualization/crysview-threejs-demo.html) +and demonstrates, against a non-orthogonal unit cell: - atoms as spheres with element radius and colour; - anisotropic ADP ellipsoids (semi-axis lengths plus orientation); diff --git a/docs/dev/adrs/accepted/crysview-threejs-demo.html b/docs/dev/adrs/accepted/crysview-structure-visualization/crysview-threejs-demo.html similarity index 100% rename from docs/dev/adrs/accepted/crysview-threejs-demo.html rename to docs/dev/adrs/accepted/crysview-structure-visualization/crysview-threejs-demo.html diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 9a04a6a6c..fe759d3ee 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -13,43 +13,46 @@ folders. ## ADR Index -| Group | Status | Title | Short description | Link | -| -------------------- | ---------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | -| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | -| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | -| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | -| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | -| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | -| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | -| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | -| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) | -| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | -| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | -| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | -| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | -| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | -| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | -| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | -| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | -| Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) | -| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | -| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | -| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | -| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | -| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | -| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | -| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | -| Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) | -| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | -| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | -| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | -| User-facing API | Accepted | Crystal Structure 3D Visualization | Adds a renderer-neutral scene model drawn by ASCII and interactive Three.js engines for viewing crystal structures. | [`crysview-structure-visualization.md`](accepted/crysview-structure-visualization.md) | -| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | -| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) | -| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and clean report-CIF metadata policy. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) | -| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | -| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | -| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | -| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | -| User-facing API | Accepted | Value-Selector Discovery | Gives enumerated value fields a per-descriptor `show_supported()`, beside the three category-level selector families. | [`value-selector-discovery.md`](accepted/value-selector-discovery.md) | +| Group | Status | Title | Short description | Link | +| -------------------- | ---------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Analysis and fitting | Accepted | Fit Mode Categories and Fit Execution API | Splits fitting configuration from execution and defines active sibling fit-mode categories. | [`fit-mode-categories.md`](accepted/fit-mode-categories.md) | +| Analysis and fitting | Accepted | Runtime Fit Results | Keeps full fit outputs runtime-only in the current design unless a narrower persistence ADR is accepted. | [`runtime-fit-results.md`](accepted/runtime-fit-results.md) | +| Analysis and fitting | Accepted | Analysis CIF Fit State | Defines the persisted fit-state projection in `analysis/analysis.cif` and `analysis/results.h5`. | [`analysis-cif-fit-state.md`](accepted/analysis-cif-fit-state.md) | +| Analysis and fitting | Accepted | Parameter Correlation Persistence | Persists deterministic and posterior correlation summaries in `_fit_parameter_correlation` | [`parameter-correlation-persistence.md`](accepted/parameter-correlation-persistence.md) | +| Analysis and fitting | Suggestion | Fit Output Files and Data Exports | Narrows remaining archive/export questions after adopting `results.csv` and `results.h5`. | [`fit-output-files-and-data-exports.md`](suggestions/fit-output-files-and-data-exports.md) | +| Analysis and fitting | Accepted | Minimizer Category Consolidation | Collapses the seven Bayesian categories into one owner-level switchable `minimizer` category with HDF5 sidecar. | [`minimizer-category-consolidation.md`](accepted/minimizer-category-consolidation.md) | +| Analysis and fitting | Accepted | Minimizer Input/Output Split | Keeps `analysis.minimizer` input-only and moves scalar fit outputs to paired `analysis.fit_result` classes. | [`minimizer-input-output-split.md`](accepted/minimizer-input-output-split.md) | +| Analysis and fitting | Superseded | Parameter-Level Posterior Projection | Superseded by minimizer-category consolidation; kept as historical context for `parameter.posterior`. | [`parameter-posterior-summary.md`](suggestions/parameter-posterior-summary.md) | +| Analysis and fitting | Accepted | Undo Fit | Builds rollback semantics and CLI behavior on already-persisted pre-fit scalar snapshots. | [`undo-fit.md`](accepted/undo-fit.md) | +| Core model | Accepted | Category Owners and Real Datablocks | Introduces `CategoryOwner` so singleton sections do not pretend to be real CIF datablocks. | [`category-owner-sections.md`](accepted/category-owner-sections.md) | +| Core model | Accepted | Enum-Backed Closed Value Sets | Requires finite option sets to use `(str, Enum)` classes for validation and dispatch. | [`enum-backed-closed-values.md`](accepted/enum-backed-closed-values.md) | +| Core model | Accepted | Guarded Public Properties | Uses property setters as the public writability contract for guarded objects. | [`guarded-public-properties.md`](accepted/guarded-public-properties.md) | +| Core model | Accepted | Two-Level Category Parameter Access | Keeps parameter access to `datablock.category.parameter` or `datablock.collection[id].parameter`. | [`category-parameter-access.md`](accepted/category-parameter-access.md) | +| Documentation | Accepted | Descriptor Property Docstring Template | Makes descriptor metadata the source of truth for public property docstrings and annotations. | [`property-docstring-template.md`](accepted/property-docstring-template.md) | +| Documentation | Accepted | Development Documentation Structure | Defines the `docs/dev` layout for ADRs, issues, plans, package structure, and roadmap. | [`development-docs-structure.md`](accepted/development-docs-structure.md) | +| Documentation | Accepted | Help Method Discoverability | Requires primary public objects and facades to expose consistent `help()` output. | [`help-discoverability.md`](accepted/help-discoverability.md) | +| Documentation | Accepted | Notebook Generation Source of Truth | Treats tutorial `.py` files as editable sources and notebooks as generated artifacts. | [`notebook-generation.md`](accepted/notebook-generation.md) | +| Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) | +| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) | +| Experiment model | Suggestion | Automatic Line-Segment Background Estimation | Detects line-segment background control points from the measured pattern, peak-insensitive and editable. | [`background-auto-estimate.md`](suggestions/background-auto-estimate.md) | +| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) | +| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) | +| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) | +| Persistence | Accepted | Loop Category Keys and Identity Naming | Documents loop collection keys and naming rules aligned with CIF category keys. | [`loop-category-key-identity.md`](accepted/loop-category-key-identity.md) | +| Persistence | Accepted | Project Facade and Persistence Layout | Documents the current `Project` facade and saved directory layout. | [`project-facade-and-persistence.md`](accepted/project-facade-and-persistence.md) | +| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) | +| Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) | +| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | +| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | +| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | +| Structure model | Suggestion | Automatic Wyckoff Position Detection | Detects Wyckoff letter, multiplicity, and site symmetry from space group and coordinates; calculators consume them. | [`wyckoff-letter-detection.md`](suggestions/wyckoff-letter-detection.md) | +| Structure model | Suggestion | Complete Space-Group Reference Database | One-time build of a complete space_groups.json.gz (all 230 groups) from cctbx, verified against multiple sources. | [`space-group-database.md`](suggestions/space-group-database.md) | +| User-facing API | Accepted | Crystal Structure 3D Visualization | Adds a renderer-neutral scene model drawn by ASCII and interactive Three.js engines for viewing crystal structures. | [`crysview-structure-visualization.md`](accepted/crysview-structure-visualization.md) | +| User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | +| User-facing API | Accepted | Fit Results Display Naming | Short, IUCr/GUM-aligned column headers (`s.u.`, `value`, `95% CI`) with a footnote glossary on every fit table. | [`fit-results-display-naming.md`](accepted/fit-results-display-naming.md) | +| User-facing API | Accepted | Project Summary Rendering | Defines project report configuration plus terminal, HTML, TeX, PDF, and clean report-CIF metadata policy. | [`project-summary-rendering.md`](accepted/project-summary-rendering.md) | +| User-facing API | Accepted | Selector Families | Distinguishes backend selectors, switchable-category selectors, and active-sibling selectors. | [`selector-families.md`](accepted/selector-families.md) | +| User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | +| User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | +| User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | +| User-facing API | Accepted | Value-Selector Discovery | Gives enumerated value fields a per-descriptor `show_supported()`, beside the three category-level selector families. | [`value-selector-discovery.md`](accepted/value-selector-discovery.md) | diff --git a/docs/dev/adrs/suggestions/background-auto-estimate.md b/docs/dev/adrs/suggestions/background-auto-estimate.md new file mode 100644 index 000000000..1baca4e89 --- /dev/null +++ b/docs/dev/adrs/suggestions/background-auto-estimate.md @@ -0,0 +1,532 @@ +# ADR: Automatic Line-Segment Background Estimation + +**Status:** Proposed **Date:** 2026-06-01 + +## Group + +Experiment model. + +> This ADR follows [`AGENTS.md`](../../../../AGENTS.md). It adds one new +> dependency, `pybaselines` (§4). The user approved it directly in the +> drafting conversation, which is the explicit approval +> [`AGENTS.md`](../../../../AGENTS.md) → **Architecture** requires. The +> implementation plan must still **name `pybaselines` explicitly** in +> its dependency-changing step (for example a +> `P1.x — Add pybaselines dependency` line): `/draft-impl-1` and +> `/draft-impl-2` are authorized to edit `pyproject.toml`, `pixi.toml`, +> and `pixi.lock` only by the accepted plan text naming the package, not +> by this drafting thread's approval alone. No other deliberate +> exception to those instructions is taken. + +## Context + +A line-segment background is a set of `(x, intensity)` control points +that are linearly interpolated across the pattern +([`line_segment.py:147`](../../../../src/easydiffraction/datablocks/experiment/categories/background/line_segment.py)). +Today the user must supply every point by hand — +`experiment.background.create(id='1', x=12.0, y=85.0)` in Python, or a +`_pd_background.*` loop in CIF. With no points, the model evaluates to +zero +([`line_segment.py:175`](../../../../src/easydiffraction/datablocks/experiment/categories/background/line_segment.py)). +There is no automatic estimation anywhere in the library. + +Hand-placing points well is tedious and easy to get wrong, and the +audience is scientists who are often not programmers. Two rules make it +genuinely hard: + +1. **Points must flank peaks, not sit on them.** A point placed on a + peak shoulder pulls the interpolated background up _into_ the peak + and steals intensity from the very quantity being refined. +2. **In strongly overlapped regions there is no true background point.** + The pattern never returns to baseline between dense reflections, so + the valley floor sits _above_ the real background. Naively picking + local minima there inflates the background and biases integrated + intensities low. + +A third complication is specific to constant-wavelength (CWL) data and +to _when_ an automatic background is typically wanted. CWL peak width is +**not constant** — FWHM grows with angle (the Caglioti +`U·tan²θ + V·tanθ + W` trend) — so a single "peak width" is already an +approximation across the pattern. Worse, an automatic background is +usually reached for at the very **first** modelling step, when the +peak-profile parameters (`U`, `V`, `W` on `self._parent.peak`) are only +roughly set. Any width taken from that unrefined resolution model would +be badly wrong exactly when the feature is first used. + +The resolution of that timing problem is to never derive the width from +the _model_: the **measured pattern already contains the true peak +widths**, and those are independent of how well the profile parameters +are set. Measuring the width directly from `data.intensity_meas` is +therefore reliable from step one. It also reframes the iterative +workflow the user described — _estimate a background, refine the rest of +the model, then re-estimate_ — correctly: re-running does **not** +improve the measured peak widths (the data does not change). What it +gains is the **fitted model**. After at least one calculation, +`data.intensity_calc` (the total model) and `data.intensity_bkg` are +populated +([`bragg_pd.py:574`](../../../../src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py), +[`bragg_pd.py:582`](../../../../src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py)), +so the peak-only model `intensity_calc − intensity_bkg` becomes +available. Subtracting it from the measured pattern removes the fitted +peaks while keeping the background, giving a peak-subtracted pattern on +which a second pass estimates the absolute background and places better +anchors — especially across overlapped clusters the data alone cannot +resolve. (§5 gives the exact array and shows why the emitted heights are +absolute background values, not residual corrections.) So re-estimation +is a first-class workflow, not just a convenience. + +This is a well-studied problem in powder diffraction. The classic +peak-clipping methods (Sonneveld & Visser, 1975; Brückner, 2000) and the +SNIP algorithm estimate a smooth background _underneath_ the peaks, even +where the data never reaches it. The de-facto Python library is +`pybaselines` (50+ algorithms; its `classification` family also returns +a boolean mask of which points are baseline); notably, **GSAS-II's +automatic fixed-point background (`autoBkgCalc`) is a thin wrapper +around `pybaselines`** feeding exactly this fixed-point model. + +Everything an estimator needs is already reachable from the category. +The existing `_update()` reads the live pattern through the parent +([`line_segment.py:172`](../../../../src/easydiffraction/datablocks/experiment/categories/background/line_segment.py)): +`self._parent.data` exposes `data.x`, `data.intensity_meas`, +`data.intensity_calc`, and `data.intensity_bkg` as NumPy arrays over the +**active** points — excluded regions are already filtered out +([`bragg_pd.py:539`](../../../../src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py), +[`bragg_pd.py:679`](../../../../src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py)). +The parent also carries the experiment-type axes +(`self._parent.type.beam_mode.value`). + +The produced points are first-class and need no new persistence: each +point's `intensity` is a `Parameter` with a `free` flag +([`variable.py:447`](../../../../src/easydiffraction/core/variable.py)) +persisted through the existing free/fixed CIF encoding +([`free-flag-cif-encoding.md`](../accepted/free-flag-cif-encoding.md)), +and the `_pd_background.*` loop already round-trips them. So an +auto-estimator's only job is to compute good `(x, intensity)` values and +write them into the collection; the user then reviews them and chooses, +per point, fixed or refinable. + +`background` is a Family-A switchable category +([`switchable-category-owned-selectors.md`](../accepted/switchable-category-owned-selectors.md)); +`LineSegmentBackground` is the default type +([`factory.py`](../../../../src/easydiffraction/datablocks/experiment/categories/background/factory.py)). +Point-based estimation is specific to the line-segment model +(`ChebyshevPolynomialBackground` has coefficients, not points), so the +new behaviour attaches to the concrete line-segment class, not to the +shared switchable surface. + +## Decision + +### 1. A user-invoked `auto_estimate()`, never automatic + +Add a public method to `LineSegmentBackground`: + +```python +def auto_estimate( + self, + *, + method: str = 'auto', + width: float | None = None, + smoothness: float | None = None, + n_points: int | None = None, + use_model: bool = True, +) -> None: + """Detect background control points from the measured pattern.""" +``` + +A bare `experiment.background.auto_estimate()` must work — no required +arguments, no manual tuning (§3 makes that real). It reads +`self._parent.data`, computes points, and writes them into the +collection. It is the seed of the iterative loop in §5, not a one-shot. +It returns `None` (it fills the collection, like other category +mutators) and logs a one-line summary — chosen method, width, and point +count — for the review step. + +It is **explicitly on-demand**, never run inside `_update()` or at +calculation time. The library does not silently estimate or re-estimate +its own background while fitting — that would contradict the project's +"no runtime self-validation of generated output" stance and would +surprise a user who has hand-tuned points. + +The method is discoverable via the category's `help()` per +[`help-discoverability.md`](../accepted/help-discoverability.md). +`method` is a keyword argument validated against a closed +`BackgroundEstimatorMethodEnum` with exactly four Phase-1 members — +`auto`, `snip`, `arpls`, `fabc` — per +[`enum-backed-closed-values.md`](../accepted/enum-backed-closed-values.md). +`auto` is the default and a stable alias for "let the library choose"; +in Phase 1 it resolves to the single default method, `arpls` (§3). The +argument selects an algorithm for _this call only_ — it is not a +persisted descriptor and appears in no CIF block; the generated points +are the sole persisted output. The remaining overrides are continuous +numbers or booleans. No `**kwargs` (per +[`AGENTS.md`](../../../../AGENTS.md) → **Code Style**). + +### 2. Two-stage algorithm: estimate the curve, then place sparse anchors + +The hard problem (overlap) and the easy problem (anchor placement) are +decoupled. + +**Stage 1 — a peak-insensitive background curve `B(x)`.** Estimate a +smooth background over the whole grid using a method that reconstructs +the curve _under_ the peaks (peak-clipping / penalised least squares), +not the raw valley floor. This is what makes overlap regions correct: +even where the data never returns to baseline, `B(x)` is extrapolated +from the surrounding clipped trend. + +**Stage 2 — thin `B(x)` to a minimal set of line segments.** Reduce the +dense curve to a sparse `(x, intensity)` set with +Ramer–Douglas–Peucker-style polyline simplification: many anchors where +the background curves, few on flat stretches, with the first and last +grid points always kept so interpolation covers the full range. +Optionally cap the count at `n_points`. + +Two invariants protect peak intensities: + +- **Anchor heights come from `B(x)`, never from the raw data.** This is + the single most important rule against intensity inflation: even when + an anchor lands in a shallow valley whose floor is above the true + background, its height is the de-peaked `B(x)` value. +- **Each anchor's height is clipped to + `0 ≤ intensity ≤ intensity_meas`** — always against the original + measured intensities, in both the data-only and model-guided paths + (never against the peak-subtracted array). A background cannot exceed + the observation, and (for these probes) cannot be negative. + +**Overlap is handled by abstention, stated honestly.** Inside a dense +multiplet there is no information to place a true background point from +the data alone, so the estimator deliberately does **not** force one +there — one segment spans the cluster, interpolating across it using the +de-peaked `B(x)` at the cluster's flanks. (A model-guided re-run, §5, +can do better because it knows where the peaks are.) + +### 3. Auto-parameterization: adapt to the dataset and the experiment type + +Every algorithm in Stage 1 keys off one length scale — the peak width in +data points. A fixed default (e.g. a 50-point window) is right for one +dataset and wrong for the next, because CWL 2θ steps, TOF time bins, lab +vs synchrotron resolution, and neutron vs X-ray all differ. +`auto_estimate()` therefore derives its parameters at call time: + +- **Peak width `W` (in points) is measured from the data, not the + model.** `scipy.signal.find_peaks` on the most prominent peaks then + `scipy.signal.peak_widths`. Because CWL FWHM grows with angle + (§Context), `W` is taken as a robust **upper** estimate (a high + percentile of the measured widths), so the Stage-1 window/smoothness + is large enough to clear the broadest peaks; mildly over-smoothing the + background under the sharp low-angle peaks is harmless, since the + background is smooth there anyway. The peak/resolution model + (`self._parent.peak`) is **not** used by default — at the typical + first-use moment its `U/V/W` are unrefined and would mislead. A future + opt-in could consult it once refined, but the data-derived width is + correct from step one and is the default. +- **Noise σ** — a robust estimate from the median absolute deviation of + the second difference of the intensities (insensitive to peaks). Feeds + the classification threshold and the RDP tolerance (`c · σ`), so the + number of anchors follows the real background curvature rather than a + magic count. +- **Window / smoothness / threshold** — derived from `W`, σ, and the + point count `N`, then handed to the backend (§4). +- **Algorithm choice** — one method everywhere to start. A single robust + penalised-least-squares default (proposed `arpls`, whose one global + smoothness parameter handles both the CWL angular width spread and TOF + curvature) is used for every experiment, with all per-dataset + adaptation coming from the auto-derived width, noise, and tolerance + above; `method=` selects a specific algorithm explicitly. A + `beam_mode`- or `radiation_probe`-specific policy is **not** + introduced on speculation — the single default and the exact constants + are confirmed by benchmarking against the tutorial corpus (§Testing), + and a per-type policy is added only if that corpus shows one method is + not enough (§Deferred Work). + +The whole path is deterministic (no RNG), so reruns on the same inputs +yield identical points. When width or peak detection degenerates (too +few points, no detectable peaks), the estimator falls back to a +conservative metadata-derived width and emits **one** clear +`log.warning` telling the user to inspect and adjust — it does not +silently emit a bad background. + +### 4. Backend: `pybaselines` (approved dependency) plus an in-house layer + +`pybaselines` is added as a project dependency (BSD-3; runtime deps +NumPy and SciPy, both already required) and supplies **Stage 1**: the +peak-insensitive curve `B(x)` (`snip`, `arpls`) and, for the +classification methods (`fabc`, `fastchrom`, `dietrich`), a boolean +baseline `mask` with a `min_length` guard that is a natural +candidate-anchor pool abstaining in overlap. This is the same library +GSAS-II's `autoBkgCalc` delegates to. + +The in-house layer owns everything `pybaselines` cannot know about a +diffraction pattern: the §3 auto-parameterization (data-derived width, +noise, and resolving `method='auto'` to the single default), the Stage-2 +thinning and `[0, measured]` clipping, the model-guided re-estimation +(§5), and the point lifecycle. `pybaselines`' library defaults are +deliberately generic and would be wrong per-dataset, so the value is in +feeding it the right parameters, not in calling it raw. + +### 5. Re-estimation is a first-class workflow + +The intended usage is a loop, and the API supports it directly: + +1. `auto_estimate()` early, from the measured data alone (no model yet) + — a fixed starting background. +2. Refine the rest of the model (cell, scale, peak profile, …), with the + background fixed or, point-by-point, freed. +3. `auto_estimate()` **again**. Now `data.intensity_calc` is populated, + so with `use_model=True` (default) the estimator forms the peak-only + model array `intensity_calc − intensity_bkg` and passes the Stage-1 + helper the **peak-subtracted measured intensities** + + `y = intensity_meas − (intensity_calc − intensity_bkg)` + + — the measured pattern with the fitted peaks removed, **not** the fit + residual `intensity_meas − intensity_calc`. Because only the + peak-only model is subtracted (the current background stays in `y`), + the baseline `B(x)` estimated from `y` is the **absolute** + background, so the emitted control points are absolute + `_pd_background` heights and no add-back of `intensity_bkg` is + needed. The same peak-only model array also yields the model's peak + positions (the existing `find_peaks` pass, run on it rather than on + the raw data), which place anchors at better **x positions** — in + genuine inter-peak gaps, including across overlapped clusters the + data alone could not resolve. Everything comes only from the + backend-independent `data.intensity_meas` / `data.intensity_calc` / + `data.intensity_bkg` arrays, so the improvement is identical for + every calculator (Cryspy, CrysFML); it does **not** read + `experiment.refln` reflection metadata, which is calculator-specific + and may be absent or cleared. The data-only path — no calculation yet + (`intensity_calc` all zero), or `use_model=False` — passes + `y = intensity_meas` instead; both paths estimate an absolute + background and clip heights to the original measured intensities + (§2). + +**Every call overwrites and re-fixes.** `auto_estimate()` always clears +the collection and rebuilds it — there is no append mode — and the +rebuilt points are **fixed** (`free=False`) regardless of whether the +previous points had been freed during refinement. A second call is +therefore a fresh fixed seed, not a merge: calling it again overwrites +the points and re-fixes them even if they were free. This keeps the loop +predictable (each pass starts from a clean, fixed background) and +idempotent (same inputs → same points). Clearing everything — including +any hand-added points — is the deliberate "overwrite" contract; +preserving manual points is deferred. When the collection is non-empty, +the call logs a one-line notice that it is replacing the existing +points, so a user who hand-tuned a background is not surprised; the +first call, with nothing to replace, is silent. + +**Always fixed; no `free` argument.** Generated points are always +created fixed (`intensity.free = False`) — there is no caller-selectable +free option, so the "always re-fixes" contract above holds without +exception. The user reviews the points and flips individual ones — or +all — to refinable (`point.y.free = True`) afterward. This matches +fixed-point background practice and the stated review-then-refine +workflow. + +**Mechanics.** Points get sequential string ids (`'1', '2', …`) +consistent with the existing `LineSegment.id` descriptor and its CIF +tag. Excluded regions are honoured for free, since `data.*` iterate +active points only. + +### 6. Where the code lives + +A backend-agnostic estimator helper — +`estimate_background_curve(x, y, *, beam_mode, peaks=None, width=None, ...) -> (curve, anchors)` +— lives in a new small module in the background package (e.g. +`datablocks/experiment/categories/background/estimate.py`). It is pure +array-in/array-out (the optional `peaks` argument carries model peak +positions detected from the peak-only model array per §5 — not +reflection metadata), holds no model state, wraps `pybaselines` for +Stage 1, and keeps the §3 parameterization and Stage-2 thinning in-house +— so it stays unit-testable in isolation and pulls no domain logic into +`core/`. `LineSegmentBackground.auto_estimate()` is a thin adapter: read +the pattern (and model, if present), call the helper, clip, and +`create()` the points. Helpers are extracted as needed to stay under the +lint complexity thresholds +([`lint-complexity-thresholds.md`](../accepted/lint-complexity-thresholds.md)) +rather than raising them. + +The same helper can later serve `ChebyshevPolynomialBackground` (fit its +coefficients to `B(x)`), but that is **not** built now — see _Deferred +Work_ — to avoid an abstraction before its second concrete use. + +## Open Questions + +The four design questions raised in review are resolved: noise-relative +Stage-2 thinning (§3), always-overwrite with a replace notice (§5), a +single Stage-1 method for now (§3), and a void method that logs a +one-line summary (§1). What remains is empirical calibration, done +against the tutorial corpus during implementation: + +- The exact Stage-2 tolerance multiplier (`c · σ`, proposed `c ≈ 2`) and + the width percentile (proposed ~75th) need tuning against real + datasets. +- Whether the single Stage-1 method holds across the whole corpus + (CWL/TOF, neutron/X-ray) or a `beam_mode`/`radiation_probe` policy is + eventually needed (see §Deferred Work). + +## Consequences + +### Positive + +- One call, no arguments, gives scientists a sensible, reviewable + starting background — including in overlap regions, where heights come + from the de-peaked curve. +- Robust across datasets _and_ experiment types because the length scale + is measured from the data per call (§3) rather than hardcoded — and + reliable at the first modelling step, when the resolution model is + not. +- Supports the natural estimate → refine → re-estimate loop (§5): a + later pass uses the fitted model to improve anchor placement, and + re-running is safe, idempotent, and re-fixes the points. +- Output is ordinary line-segment points: editable, individually + fixable/refinable, and already CIF-persisted — **no new CIF tags or + serialization work**. +- Reuses the same backend (`pybaselines`) the GSAS-II fixed-point + background relies on. + +### Trade-offs + +- Adds one dependency, `pybaselines` (approved; BSD-3, + NumPy/SciPy-only). +- Not infallible with literally zero input: amorphous/diffuse humps and + pathological overlap can still bias the first (data-only) pass. The + honest contract is "a good starting estimate you then refine," + surfaced by a warning when the estimate is unreliable — not a + guarantee of correctness. +- Adds an estimator module and a new public method to maintain. + +### Compatibility Outcomes + +- Purely additive: the manual `create()` workflow, existing projects, + and the default background type are all unchanged. Nothing auto-runs. +- A project saved after `auto_estimate()` is an ordinary line-segment + background; it reloads with no new fields. + +## Alternatives Considered + +- **Call `pybaselines` with its library defaults.** Rejected: its + generic defaults (a one-size `lam`, an untuned SNIP window) are wrong + per-dataset, so the §3 auto-parameterization layer is needed + regardless. `pybaselines` supplies the curve; the diffraction-aware + parameters come from us. +- **Derive the peak width from the resolution model (`U/V/W`).** + Rejected as the default: the model is unrefined at the typical + first-use moment and would give a badly wrong width — the user's own + observation. The measured pattern carries the true widths and is + model-independent. +- **Ship a built-in estimator and make `pybaselines` optional.** + Rejected now that the dependency is approved: a single hard backend + removes import-availability branching and two divergent code paths, + and gives the better algorithms (`arpls`, `fabc`, the classification + mask) unconditionally. +- **Naive valley / local-minima picking on the raw data.** Rejected: it + inflates the background in overlapped regions — the exact problem in + §Context. +- **Run estimation automatically at calculation time** (fill if empty). + Rejected: hides a modelling choice, fights hand-tuned points, and + re-validates generated output at runtime — all against project + principles. +- **Merge or append, rather than overwrite, on re-run** (keep + freed/refined points; an earlier draft exposed a `replace=False` + append mode). Rejected: it makes the loop unpredictable and lets a + stale point survive a better estimate, and no real use case justified + the second mode. Every call overwrites and re-fixes — one predictable + behaviour. +- **Generalise `auto_estimate()` onto `BackgroundBase` now** so + Chebyshev shares it. Deferred: the shared _estimator helper_ (§6) + already captures the reusable core; a base-level method awaits the + second implementation. + +## Testing + +Per [`test-strategy.md`](../accepted/test-strategy.md), unit-level tests +(no calculation engine, no network, no sleeping) on the pure estimator +helper: + +- **Synthetic patterns with a known analytic background** (flat, linear + slope, smooth curve, TOF-like decay) plus planted Gaussian peaks, + including a deliberately overlapped multiplet. Assert the recovered + points reproduce the true background within tolerance, that **no + anchor lands on a planted peak**, and that none exceeds the local + data. +- **CWL angular broadening**: peaks whose FWHM grows with x. Assert the + upper-percentile width keeps the background from being pulled up under + the broad high-angle peaks. +- **Model-guided re-run**: with a supplied peak-only model over an + overlapped cluster, assert better anchor placement (in true gaps) than + the data-only pass on the same pattern, **and** that the emitted + control-point heights are absolute background values matching the + synthetic pattern's known background — not residual corrections around + the input background. +- **Re-estimation lifecycle**: a second `auto_estimate()` clears prior + points and produces **fixed** points even when the previous ones were + freed; ids stay sequential. +- **Determinism**: identical inputs → identical points. +- **Graceful degradation**: a peakless or near-empty pattern triggers + the single fallback warning rather than an exception or a garbage + background. + +**Tutorial corpus as real-world reference.** The ~25 tutorial scripts in +`docs/docs/tutorials/*.py` already build real experiments with +well-defined backgrounds across both beam modes and both probes — CWL +(e.g. the sloping background in +[`ed-17.py`](../../../../docs/docs/tutorials/ed-17.py) and +[`ed-2.py`](../../../../docs/docs/tutorials/ed-2.py)) and TOF (e.g. +[`ed-13.py`](../../../../docs/docs/tutorials/ed-13.py), +[`ed-16.py`](../../../../docs/docs/tutorials/ed-16.py)). Their +hand-placed line-segment points are ground truth: stripping them and +re-running `auto_estimate()` should reproduce a comparable background +curve within tolerance. This gives broad, real coverage across space +groups, beam modes, and probes at almost no authoring cost, and is the +reference set used to calibrate the default constants and confirm the +single Stage-1 method. These corpus checks run at the functional / +script level where the tutorial experiments are already loaded, not at +unit level. + +The estimator module mirrors into +`tests/unit/easydiffraction/datablocks/experiment/categories/background/` +per the test-structure mirror rule. + +## Deferred Work + +- A spatially-varying / per-region Stage-1 window that follows the CWL + angular broadening exactly (the upper-percentile single window is the + adequate first cut). +- Beam-mode- and radiation-probe-specific Stage-1 method/parameter + defaults, if benchmarking the single default against the tutorial + corpus (CWL/TOF, neutron/X-ray) shows one method is not enough. +- `ChebyshevPolynomialBackground.auto_estimate()` fitting coefficients + to the same `B(x)`, promoting the method to `BackgroundBase` once a + second implementation exists. +- An opt-in path that consults the peak/resolution model for the width + _once it is refined_, as a cross-check on the data-derived width. +- Using `experiment.refln` calculated reflection positions (when a + calculator provides them) as an alternative or cross-check to the peak + positions detected from the peak-only model array — deferred to keep + the model-guided path backend-independent. +- A plot/diagnostic preview of the chosen curve and points via the + display layer ([`display-ux.md`](../accepted/display-ux.md)). +- Tagging auto-generated points so a re-run can preserve hand-added + ones. +- Total-scattering backgrounds (`pdffit2`) are out of scope — the + line-segment model does not apply there. + +## Related ADRs + +- [`switchable-category-owned-selectors.md`](../accepted/switchable-category-owned-selectors.md) + — `background` is a Family-A switchable category; `auto_estimate()` is + a type-specific method on `LineSegmentBackground`, not part of the + selector surface. +- [`free-flag-cif-encoding.md`](../accepted/free-flag-cif-encoding.md) — + generated points carry a fixed/fittable `free` flag persisted through + CIF uncertainty syntax; no new tags. +- [`guarded-public-properties.md`](../accepted/guarded-public-properties.md) + — each point's `intensity` is an editable `Parameter`. +- [`enum-backed-closed-values.md`](../accepted/enum-backed-closed-values.md) + — the `method` argument is an enum-backed closed value set. +- [`help-discoverability.md`](../accepted/help-discoverability.md) — + `auto_estimate()` is surfaced in the category's `help()`. +- [`lint-complexity-thresholds.md`](../accepted/lint-complexity-thresholds.md) + — the estimator stays within complexity guardrails via extracted + helpers. +- [`test-strategy.md`](../accepted/test-strategy.md) — layered tests, + mirror rule, no engines at unit level. diff --git a/docs/dev/adrs/suggestions/plotting-docs-performance.md b/docs/dev/adrs/suggestions/plotting-docs-performance.md new file mode 100644 index 000000000..99d2cf528 --- /dev/null +++ b/docs/dev/adrs/suggestions/plotting-docs-performance.md @@ -0,0 +1,434 @@ +# ADR: Plotting & Docs Performance for Interactive Figures + +**Status:** Proposed **Date:** 2026-06-02 + +## Group + +Documentation. + +> This ADR follows [`AGENTS.md`](../../../../AGENTS.md). It spans the +> documentation build (MkDocs) and the display serialization contract, +> so it also relates to the User-facing API ADRs +> [`display-ux.md`](../accepted/display-ux.md) and +> [`crysview-structure-visualization.md`](../accepted/crysview-structure-visualization.md). +> No public Python API change is intended; the change is in how figure +> HTML and its JavaScript runtime are delivered. + +## Context + +### Symptom + +Generated tutorial pages that contain many interactive figures (mostly +Plotly, plus the occasional Three.js crystal-structure view) can take +from several to a few dozen seconds before the page becomes responsive. +The plots are valuable and should stay interactive; the goal is to keep +interactivity while making the page usable immediately and letting plots +appear progressively. + +### How figures reach a docs page today + +1. Tutorial sources are `docs/docs/tutorials/ed-*.py`; notebooks are + generated artifacts (per + [`notebook-generation.md`](../accepted/notebook-generation.md)) and + are committed with **outputs stripped** (`notebook-strip`). +2. The docs CI + ([`.github/workflows/docs.yml`](../../../../.github/workflows/docs.yml)) + runs `notebook-exec-ci` to **execute** every notebook, baking the + rendered cell outputs into the `.ipynb`, then `mkdocs build` with + `mkdocs-jupyter` configured `execute: false` simply embeds those + pre-rendered outputs into the HTML. +3. Each Plotly figure is emitted by `PlotlyPlotter._show_figure` + ([`src/easydiffraction/display/plotters/plotly.py`](../../../../src/easydiffraction/display/plotters/plotly.py)) + as a `text/html` output via + `serialize_html(fig, include_plotlyjs='cdn')` wrapped in + `IPython.display.HTML`. The resulting HTML, **per figure**, carries: + - a `
` plus an inline ` + {% endblock %} + ``` + + In `SHARED` mode the Three.js renderer then emits **only** the module + bootstrap (bare `three` / `three/addons/...` specifiers) and **no** + per-scene importmap, so every scene on a page resolves against this + single head-level map. `STANDALONE` reports are unaffected — they + keep their self-contained inline importmap (a standalone file has no + theme override). Injecting the map on every page is harmless where no + scene consumes it (the tiny JSON is inert), keeping the override + simple. + +This pays the network bill once per page from the same origin, removes +the per-figure JS duplication, and turns first paint from "render every +figure" into "render nothing until seen" — addressing both bottlenecks +while keeping every plot fully interactive. + +## Options considered + +### Option A — Tactical: lazy activation only + +Keep each figure's self-contained, CDN-loaded HTML exactly as today, but +wrap the existing per-figure post-script so `Plotly.newPlot` fires from +an `IntersectionObserver` behind a "Loading…" placeholder. + +- **Pros:** smallest change; isolated to the post-script; delivers the + "plots appear one by one" UX the request asked for. +- **Cons:** does **not** fix the network bottleneck (still CDN, still + RequireJS, Three.js still inlined per scene, importmap bug remains); + keeps ~15 KB × N duplicated post-scripts; leaves the long-term CDN + fragility for versioned docs. Robustness: low. + +### Option B — Shared self-hosted runtime + lazy activation _(recommended)_ + +As in **Decision** above: self-host pinned runtimes loaded once per +page, an explicit embedding mode, and a shared lazy loader. + +- **Pros:** fixes **both** bottlenecks; firewall-proof and archival + (versioned docs stay self-consistent); de-duplicates and centralizes + figure JS (maintainability); fixes the importmap bug; generalizes the + pattern reports already use; keeps reports self-contained. +- **Cons:** the most work now — touches `serialize_html`, the Three.js + renderer, `mkdocs.yml`, a vendoring/build step, and a new shared JS + asset; requires careful handling of the three delivery targets and of + the live-notebook experience. Robustness: high. **Matches the stated + preference to accept more work now for long-term robustness.** + +### Option C — MkDocs post-processing plugin + +Leave the Python serialization mostly as-is and add a custom MkDocs +plugin (or adopt `mkdocs-plotly-plugin`, already eyed in a `docs.yml` +comment) that post-processes built pages to strip duplicate runtimes, +inject one shared runtime, and add the lazy loader globally. + +- **Pros:** centralizes behavior in the build; minimal Python display + changes. +- **Cons:** adds a bespoke build dependency to maintain against MkDocs + and Plotly upgrades; "spooky action" in a post-build pass that is + harder to test than deterministic serialization; + `mkdocs-plotly-plugin` targets `.plotly` JSON files in Markdown, not + executed-notebook outputs, so it is not a drop-in. Robustness: medium, + but with ongoing maintenance cost and weaker testability than B. + +### Comparison + +| Concern | A — tactical | B — shared+lazy | C — plugin | +| ------------------------------------------- | ------------ | --------------- | ---------------------- | +| Plots appear progressively | ✅ | ✅ | ✅ | +| Removes runtime-CDN dependency | ❌ | ✅ | ✅ | +| Smaller runtime (partial bundle) | ❌ | ✅ | possible | +| De-duplicates per-figure JS | ❌ | ✅ | ✅ | +| Fixes Three.js importmap bug | ❌ | ✅ | maybe | +| Archival / version-frozen docs | ❌ | ✅ | ✅ | +| Reports keep `offline` contract (unchanged) | ✅ | ✅ | ✅ | +| Implementation cost now | low | high | medium | +| Long-term maintenance cost | low | low | higher (custom plugin) | +| Testable in unit tests | partial | ✅ | weak | + +## Consequences + +### Positive + +- Page is responsive immediately; figures render on demand, one by one. +- One same-origin runtime fetch per page, cached across the site; + partial bundle roughly halves the Plotly download. +- Per-figure HTML shrinks substantially (no embedded runtime, no + duplicated post-scripts), so executed `.ipynb` artifacts and built + pages are smaller. +- Versioned docs become self-consistent and archival; no runtime CDN. +- Theme-sync / resize / legend logic lives in one auditable place. +- The multiple-importmap Three.js bug is fixed. + +### Negative / cost + +- Larger change across display, report (verification only), docs build, + and a new vendored asset + build step. +- Vendored runtimes must be kept current, but the bump script + pixi + task (Decision 5) reduce this to editing a pinned version + hash and + running one task; licenses regenerate and an optional `--check` mode + guards against drift. +- The shared loader is now load-bearing for docs rendering; it needs its + own tests and a no-/failed-JS fallback story. + +### Neutral + +- No intended change to public Python API or to how authors write + tutorials; the figures look and behave the same, only faster. + +## Risks and mitigations + +- **Live-notebook rendering.** `SHARED` placeholders need the docs + loader, so they must never reach a live Jupyter session. Settled by + the env-var routing (Decision 2): only the docs notebook-execution + tasks request `SHARED`; an unset variable resolves to `INLINE`. Cover + the resolver with a unit test asserting both the default and the + docs-build override. +- **Report `offline` contract.** Keep + [`project-summary-rendering.md`](../accepted/project-summary-rendering.md) + authoritative (Decision 4); the existing `offline=True` / + `offline=False` report tests must stay green and gain no `SHARED` + behavior. +- **Partial bundle missing a trace type.** Audit every trace/type used + across tutorials and reports before pinning `plotly-cartesian`; fall + back to the full bundle if any `scattergl`/3D/map usage exists. +- **`IntersectionObserver` / no-JS / print.** Provide eager fallback + when the observer is unavailable and when `matchMedia('print')` + matches, plus a `
' + ) + return cls._wrap_html_figure(fig, html_fig) @classmethod def serialize_html( @@ -1683,6 +1882,7 @@ def serialize_html( fig: object, *, include_plotlyjs: bool | str, + mode: FigureEmbedMode = FigureEmbedMode.STANDALONE, force_template: str | None = None, axis_frame_color: str | None = None, grid_color: str | None = None, @@ -1696,6 +1896,9 @@ def serialize_html( Plotly figure to serialize. include_plotlyjs : bool | str Plotly JavaScript inclusion mode passed to Plotly. + mode : FigureEmbedMode, default=FigureEmbedMode.STANDALONE + Embedding mode. ``SHARED`` emits a lazy placeholder for the + docs loader; ``INLINE``/``STANDALONE`` serialize eagerly. force_template : str | None, default=None Optional template name applied before serialization. axis_frame_color : str | None, default=None @@ -1708,6 +1911,8 @@ def serialize_html( str Inline HTML containing the figure and helper scripts. """ + if mode is FigureEmbedMode.SHARED: + return cls._serialize_html_shared(fig) background_color = None if force_template is not None: fig.update_layout(template=force_template) @@ -1764,11 +1969,26 @@ def _apply_background_color( resolved_background = background_color if resolved_background is None: resolved_background = cls._background_color() + if cls._figure_is_correlation_heatmap(fig): + # Correlation cells carry their own colors; keep the + # area outside the cells transparent. + resolved_background = cls._paper_background_color() update_layout( - paper_bgcolor=resolved_background, + paper_bgcolor=cls._paper_background_color(), plot_bgcolor=resolved_background, ) + @classmethod + def _figure_is_correlation_heatmap(cls, fig: object) -> bool: + """ + Return whether a figure is flagged as a correlation heatmap. + """ + meta = cls._figure_meta(fig) + theme_sync = meta.get(THEME_SYNC_META_KEY) if isinstance(meta, dict) else None + if not isinstance(theme_sync, dict): + return False + return bool(theme_sync.get(THEME_SYNC_CORRELATION_HEATMAP_KEY)) + @classmethod def _get_layout( cls, @@ -1847,14 +2067,14 @@ def _get_layout( 'text': title, 'font': {'size': TITLE_FONT_SIZE}, }, - paper_bgcolor=cls._background_color(), + paper_bgcolor=cls._paper_background_color(), plot_bgcolor=cls._background_color(), legend={ 'bgcolor': cls._legend_background_color(), 'xanchor': 'right', - 'x': 1.0, + 'x': 0.99, 'yanchor': 'top', - 'y': 1.0, + 'y': 0.99, }, xaxis=xaxis, yaxis=yaxis, @@ -2021,6 +2241,36 @@ def _base_composite_height_pixels(plot_spec: PowderMeasVsCalcSpec) -> float: return float(DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT) return float(plot_spec.height) + @classmethod + def _single_main_panel_height_pixels(cls, residual_height_fraction: float) -> int: + """ + Return figure height matching the composite main panel. + + Standalone single-panel figures (e.g. posterior distribution + plots) use this so their plot area matches the pattern plot's + top panel rather than the full three-row composite. Mirrors the + baseline main-row math in ``_baseline_non_bragg_row_heights`` + for the default main + Bragg ticks + residual layout, then adds + the figure's vertical margins so the drawable area (not the + outer height) equals that panel. + + Parameters + ---------- + residual_height_fraction : float + Residual-to-main row ratio of the reference composite. + + Returns + ------- + int + Figure height in pixels. + """ + base = float(DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT) + plot_area = cls._composite_plot_area_height(base) + available = plot_area * cls._subplot_available_height_fraction(3) + non_bragg = max(available - cls._bragg_tick_symbol_height_pixels(), 1.0) + main = non_bragg / (1.0 + residual_height_fraction) + return round(main + COMPOSITE_MARGIN_TOP + COMPOSITE_MARGIN_BOTTOM) + @staticmethod def _composite_plot_area_height(full_height: float) -> float: """ @@ -2462,9 +2712,9 @@ def _configure_powder_composite_layout( legend={ 'bgcolor': self._legend_background_color(), 'xanchor': 'right', - 'x': 1.0, + 'x': 0.99, 'yanchor': 'top', - 'y': 1.0, + 'y': 0.99, }, ) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index d7f3078b1..90b0c8aca 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -2824,6 +2824,9 @@ def _build_param_distribution_plot( x_axis_range=x_axis_range, y_axis_range=y_axis_range, ) + panel_height = getattr(self._backend, '_single_main_panel_height_pixels', None) + if callable(panel_height): + fig.update_layout(height=panel_height(DEFAULT_RESID_HEIGHT)) return fig def _plot_ascii_param_distribution( @@ -3992,9 +3995,9 @@ def _plot_posterior_predictive_summary( legend={ 'bgcolor': self._plot_legend_background_color(), 'xanchor': 'right', - 'x': 1.0, + 'x': 0.99, 'yanchor': 'top', - 'y': 1.0, + 'y': 0.99, }, xaxis_title=axes_labels[0], yaxis_title=axes_labels[1], diff --git a/src/easydiffraction/display/structure/assets/colors.py b/src/easydiffraction/display/structure/assets/colors.py index 5474c43aa..460c47c31 100644 --- a/src/easydiffraction/display/structure/assets/colors.py +++ b/src/easydiffraction/display/structure/assets/colors.py @@ -7,6 +7,11 @@ from __future__ import annotations from easydiffraction.display.structure.assets.elements import ELEMENT_COLORS +from easydiffraction.display.theme import DARK_BACKGROUND_COLOR +from easydiffraction.display.theme import DARK_FOREGROUND_COLOR +from easydiffraction.display.theme import LIGHT_BACKGROUND_COLOR +from easydiffraction.display.theme import LIGHT_FOREGROUND_COLOR +from easydiffraction.display.theme import hex_to_rgb Rgb = tuple[int, int, int] @@ -19,9 +24,17 @@ # Neutral wedge colour for the vacant fraction of a mixed site. VACANCY_COLOR: Rgb = (210, 210, 210) -# Parent-independent annotation contrast colours for light/dark themes. -LIGHT_THEME: dict[str, Rgb] = {'background': (255, 255, 255), 'foreground': (33, 33, 33)} -DARK_THEME: dict[str, Rgb] = {'background': (33, 33, 33), 'foreground': (235, 235, 235)} +# Light/dark annotation contrast colours, derived from the shared +# display theme (``display/theme.py``) so plots and the structure +# view share one background/foreground source of truth. +LIGHT_THEME: dict[str, Rgb] = { + 'background': hex_to_rgb(LIGHT_BACKGROUND_COLOR), + 'foreground': hex_to_rgb(LIGHT_FOREGROUND_COLOR), +} +DARK_THEME: dict[str, Rgb] = { + 'background': hex_to_rgb(DARK_BACKGROUND_COLOR), + 'foreground': hex_to_rgb(DARK_FOREGROUND_COLOR), +} def color_for(element: str, scheme: str) -> Rgb: diff --git a/src/easydiffraction/display/structure/renderers/threejs.py b/src/easydiffraction/display/structure/renderers/threejs.py index fb154f996..3b29c7127 100644 --- a/src/easydiffraction/display/structure/renderers/threejs.py +++ b/src/easydiffraction/display/structure/renderers/threejs.py @@ -21,6 +21,8 @@ from easydiffraction.display.structure.enums import ColorSchemeEnum from easydiffraction.display.structure.renderers.base import StructureRendererBase from easydiffraction.utils._vendored.theme_detect import is_dark +from easydiffraction.utils.environment import FigureEmbedMode +from easydiffraction.utils.environment import resolve_figure_embed_mode if TYPE_CHECKING: from easydiffraction.display.structure.scene import StructureScene @@ -175,6 +177,7 @@ def render( features: frozenset[str], offline: bool = True, dark: bool | None = None, + mode: FigureEmbedMode | None = None, ) -> str: """ Render the scene as a self-contained interactive HTML document. @@ -194,6 +197,11 @@ def render( Force the dark (``True``) or light (``False``) theme. When ``None`` (default), auto-detect from the environment. Reports pass ``False`` so the view matches their light page. + mode : FigureEmbedMode | None, default=None + Embedding mode; ``None`` resolves from the environment. + ``SHARED`` (docs) omits the per-scene import map and relies + on the page-level one; ``INLINE``/``STANDALONE`` emit a + per-scene import map per ``offline``. Returns ------- @@ -202,11 +210,20 @@ def render( """ if dark is None: dark = is_dark() + if mode is None: + mode = resolve_figure_embed_mode() colours = theme_colors(dark=dark) light_colours = theme_colors(dark=False) dark_colours = theme_colors(dark=True) payload = json.dumps(_scene_payload(scene)).replace('
+
Loading 3D view…
@@ -21,7 +22,7 @@ --cv-axis-letter-size: 18px; } #{{ container_id }}.cv-theme-dark { - --cv-scene-bg: transparent; + --cv-scene-bg: {{ dark_background }}; --cv-scene-fg: {{ dark_foreground }}; --cv-label-shadow-bg: {{ dark_background }}; --cv-panel-bg: {{ dark_panel_background }}; @@ -37,7 +38,7 @@ --cv-select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M2 4l3 3 3-3' fill='none' stroke='%23ebebeb' stroke-width='1.3'/%3E%3C/svg%3E"); } #{{ container_id }}.cv-theme-light { - --cv-scene-bg: transparent; + --cv-scene-bg: {{ light_background }}; --cv-scene-fg: {{ light_foreground }}; --cv-label-shadow-bg: {{ light_background }}; --cv-panel-bg: {{ light_panel_background }}; @@ -114,7 +115,9 @@ } +{% if import_map %} +{% endif %} diff --git a/src/easydiffraction/display/theme.py b/src/easydiffraction/display/theme.py index 727dbf36f..3b8f4620f 100644 --- a/src/easydiffraction/display/theme.py +++ b/src/easydiffraction/display/theme.py @@ -6,22 +6,51 @@ from dataclasses import dataclass -LIGHT_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)' -DARK_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)' -LIGHT_FOREGROUND_COLOR = '#222222' +# Background inside the axes rectangle (the plotted area / 3D scene). +# Legend background mirrors the opaque theme base surface at 50% opacity +# Figure paper (margins around the axes) stays transparent so charts +# blend into the host page; only the plotted area is opaque. + +DARK_BACKGROUND_COLOR = '#212121' DARK_FOREGROUND_COLOR = '#e6e8ee' -LIGHT_AXIS_FRAME_COLOR = '#e0e0e0' -DARK_AXIS_FRAME_COLOR = '#333' +DARK_AXIS_FRAME_COLOR = '#444' +DARK_INNER_TICK_GRID_COLOR = '#2a2a2a' +DARK_HOVER_BACKGROUND_COLOR = '#212121' +DARK_LEGEND_BACKGROUND_COLOR = 'rgba(33, 33, 33, 0.5)' + +LIGHT_BACKGROUND_COLOR = '#ffffff' +LIGHT_FOREGROUND_COLOR = '#222222' +LIGHT_AXIS_FRAME_COLOR = '#d3d3d3' LIGHT_INNER_TICK_GRID_COLOR = '#f2f2f2' -DARK_INNER_TICK_GRID_COLOR = '#1c1c1c' LIGHT_HOVER_BACKGROUND_COLOR = '#ffffff' -DARK_HOVER_BACKGROUND_COLOR = '#212121' -# Legend background mirrors the opaque theme base surface at 50% opacity LIGHT_LEGEND_BACKGROUND_COLOR = 'rgba(255, 255, 255, 0.5)' -DARK_LEGEND_BACKGROUND_COLOR = 'rgba(33, 33, 33, 0.5)' + +PAPER_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0)' + TABLE_AXIS_FRAME_CSS_VAR = '--ed-axis-frame-color' +def hex_to_rgb(value: str) -> tuple[int, int, int]: + """ + Return the RGB triple for a hex color string. + + Parameters + ---------- + value : str + Hex color in ``#rgb`` or ``#rrggbb`` form. + + Returns + ------- + tuple[int, int, int] + Red, green, and blue components in the 0-255 range. + """ + shorthand_length = 3 + digits = value.lstrip('#') + if len(digits) == shorthand_length: + digits = ''.join(channel * 2 for channel in digits) + return (int(digits[0:2], 16), int(digits[2:4], 16), int(digits[4:6], 16)) + + @dataclass(frozen=True) class DisplayThemeColors: """ diff --git a/src/easydiffraction/report/data_context.py b/src/easydiffraction/report/data_context.py index ce6fe4ca9..2cbe44ac5 100644 --- a/src/easydiffraction/report/data_context.py +++ b/src/easydiffraction/report/data_context.py @@ -1020,14 +1020,14 @@ def _plain_unit_text(value: str) -> str: """Return plain unit text normalized for report display.""" return ( value - .replace('degrees_squared', 'deg^2') - .replace('degree_squared', 'deg^2') - .replace('degrees squared', 'deg^2') - .replace('degree squared', 'deg^2') + .replace('degrees_squared', 'deg²') + .replace('degree_squared', 'deg²') + .replace('degrees squared', 'deg²') + .replace('degree squared', 'deg²') .replace('degrees', 'deg') .replace('degree', 'deg') - .replace('deg²', 'deg^2') - .replace('°²', 'deg^2') + .replace('deg^2', 'deg²') + .replace('°²', 'deg²') .replace('°', 'deg') ) diff --git a/src/easydiffraction/report/html_renderer.py b/src/easydiffraction/report/html_renderer.py index b7080b49c..d20656a90 100644 --- a/src/easydiffraction/report/html_renderer.py +++ b/src/easydiffraction/report/html_renderer.py @@ -18,6 +18,7 @@ from easydiffraction.display.plotting import DEFAULT_BRAGG_ROW from easydiffraction.display.plotting import DEFAULT_RESID_HEIGHT from easydiffraction.report.style import report_style_context +from easydiffraction.utils.environment import FigureEmbedMode _TEMPLATE_NAME = 'html/report.html.j2' _MATHJAX_FILENAME = 'mathjax-tex-mml-chtml.js' @@ -225,6 +226,7 @@ def _structure_figure_html_context( features=features, offline=offline, dark=False, + mode=FigureEmbedMode.STANDALONE, ) return rendered @@ -323,6 +325,7 @@ def _figure_html( return PlotlyPlotter.serialize_html( figure, include_plotlyjs=include_plotlyjs, + mode=FigureEmbedMode.STANDALONE, force_template='plotly_white', axis_frame_color=str(report_style['axis_hex']), grid_color=str(report_style['chart_grid_hex']), diff --git a/src/easydiffraction/utils/environment.py b/src/easydiffraction/utils/environment.py index 0fd35ce0c..b968e4a6c 100644 --- a/src/easydiffraction/utils/environment.py +++ b/src/easydiffraction/utils/environment.py @@ -6,11 +6,13 @@ import os import sys import tempfile +from enum import StrEnum from importlib.util import find_spec from pathlib import Path _ARTIFACT_ROOT_ENV_VAR = 'EASYDIFFRACTION_ARTIFACT_ROOT' _PIXI_PROJECT_ROOT_ENV_VAR = 'PIXI_PROJECT_ROOT' +_FIGURE_EMBED_MODE_ENV_VAR = 'EASYDIFFRACTION_FIGURE_EMBED_MODE' _TUTORIALS_DIR = Path('docs') / 'docs' / 'tutorials' _TUTORIAL_ARTIFACT_ROOT = Path('tmp') / 'tutorials' @@ -201,6 +203,59 @@ def create_artifact_temp_dir(prefix: str) -> Path: return Path(tempfile.mkdtemp(prefix=prefix, dir=artifact_root)).resolve() +# ---------------------------------------------------------------------- +# Figure embedding mode +# ---------------------------------------------------------------------- + + +class FigureEmbedMode(StrEnum): + """ + How interactive figure HTML embeds its JavaScript runtime. + + ``INLINE`` renders eagerly for live Jupyter; ``SHARED`` emits a lazy + placeholder activated by a once-per-page shared runtime for the docs + site; ``STANDALONE`` renders an eager self-contained fragment for + reports, with runtime delivery decided by the caller's ``offline`` + flag. + """ + + INLINE = 'inline' + SHARED = 'shared' + STANDALONE = 'standalone' + + +def resolve_figure_embed_mode() -> FigureEmbedMode: + """ + Resolve the active figure embedding mode from the environment. + + Reads ``EASYDIFFRACTION_FIGURE_EMBED_MODE``. An unset or empty value + resolves to :attr:`FigureEmbedMode.INLINE`. Any other value must + name a supported mode; an unknown value raises ``ValueError`` so a + typo in a docs or CI environment fails the build loudly instead of + silently falling back to eager output. + + Returns + ------- + FigureEmbedMode + The resolved embedding mode. + + Raises + ------ + ValueError + If the variable is set to a non-empty value that is not a + supported mode. + """ + raw = os.environ.get(_FIGURE_EMBED_MODE_ENV_VAR, '').strip() + if not raw: + return FigureEmbedMode.INLINE + try: + return FigureEmbedMode(raw.lower()) + except ValueError: + supported = ', '.join(mode.value for mode in FigureEmbedMode) + message = f'Invalid {_FIGURE_EMBED_MODE_ENV_VAR}={raw!r}; supported values: {supported}.' + raise ValueError(message) from None + + # ---------------------------------------------------------------------- # IPython/Jupyter helpers # ---------------------------------------------------------------------- diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py index 253bb4565..cc7201052 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/peak/test_cwl_mixins.py @@ -14,6 +14,8 @@ def test_cwl_pseudo_voigt_params_exist_and_settable(): assert peak.broad_gauss_u.name == 'broad_gauss_u' peak.broad_gauss_u = 0.123 assert peak.broad_gauss_u.value == 0.123 + # Squared-degree units render with a Unicode superscript. + assert peak.broad_gauss_u.resolve_display_units('gui') == 'deg²' def test_cwl_split_pseudo_voigt_adds_empirical_asymmetry(): diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 17faf6381..51245c04c 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -43,7 +43,7 @@ def test_get_layout_sets_title_axis_and_theme_colors( assert layout.title.font.size == pp.TITLE_FONT_SIZE assert layout.xaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE assert layout.yaxis.title.font.size == pp.AXIS_TITLE_FONT_SIZE - assert layout.paper_bgcolor == background_color + assert layout.paper_bgcolor == pp.PAPER_BACKGROUND_COLOR assert layout.plot_bgcolor == background_color assert layout.xaxis.linecolor == axis_color assert layout.yaxis.linecolor == axis_color @@ -242,10 +242,12 @@ def __init__(self, html): assert f"innerTickGrid: '{pp.LIGHT_INNER_TICK_GRID_COLOR}'" in captured['post_script'] assert f"hoverBackground: '{pp.DARK_HOVER_BACKGROUND_COLOR}'" in captured['post_script'] assert f"legend: '{pp.DARK_LEGEND_BACKGROUND_COLOR}'" in captured['post_script'] - assert 'ed-plotly-modebar-theme-style' in captured['post_script'] + assert "'modebar.color'" in captured['post_script'] + assert "'modebar.activecolor'" in captured['post_script'] + assert 'rgbaFromColor' in captured['post_script'] + # Modebar icons are also themed via a class-based !important rule so + # they stay visible regardless of Plotly's inline fills. assert 'ed-plotly-themed-modebar' in captured['post_script'] - assert '--ed-plotly-modebar-icon-color' in captured['post_script'] - assert '--ed-plotly-modebar-icon-hover-opacity' in captured['post_script'] assert 'const correlationColorscale = function (colors) {' in captured['post_script'] assert 'const themeSync = meta.ed_plotly_theme_sync;' in captured['post_script'] assert 'const applyAnnotationTheme = function (update, colors) {' in captured['post_script'] @@ -1123,3 +1125,123 @@ def fake_show_figure(self, fig): assert len(fig.data) == 3 assert fig.layout.yaxis2.range[0] == pytest.approx(-1.0) assert fig.layout.yaxis2.range[1] == pytest.approx(1.0) + + +def test_typed_arrays_to_float32_transcodes_and_preserves_shape(): + import base64 + + import easydiffraction.display.plotters.plotly as pp + + values = np.arange(6, dtype='' in html assert 'crysview-' in html - def test_light_theme_embeds_transparent_canvas_and_contrast_colours(self): - # ``theme_colors(dark=False)`` returns LIGHT_THEME; its background - # and foreground must be wired into the document for labels. + def test_light_theme_embeds_scene_background_and_contrast_colours(self): + # ``theme_colors(dark=False)`` returns LIGHT_THEME; its opaque + # scene background and foreground must be wired into the document. html = ThreeJsStructureRenderer().render( _identity_scene(), features=frozenset(), offline=True, dark=False, ) - assert '--cv-scene-bg: transparent;' in html + assert '--cv-scene-bg: rgb(255, 255, 255);' in html assert '--cv-label-shadow-bg: rgb(255, 255, 255);' in html - assert 'rgb(33, 33, 33)' in html # LIGHT_THEME foreground + assert 'rgb(34, 34, 34)' in html # LIGHT_THEME foreground assert 'light' in html + # Scene background is also painted via the WebGL clear color. + assert 'renderer.setClearColor' in html - def test_dark_theme_embeds_transparent_canvas_and_contrast_colours(self): + def test_dark_theme_embeds_scene_background_and_contrast_colours(self): # ``theme_colors(dark=True)`` returns DARK_THEME instead. html = ThreeJsStructureRenderer().render( _identity_scene(), @@ -776,7 +778,36 @@ def test_dark_theme_embeds_transparent_canvas_and_contrast_colours(self): offline=True, dark=True, ) - assert '--cv-scene-bg: transparent;' in html + assert '--cv-scene-bg: rgb(33, 33, 33);' in html assert '--cv-label-shadow-bg: rgb(33, 33, 33);' in html - assert 'rgb(235, 235, 235)' in html # DARK_THEME foreground + assert 'rgb(230, 232, 238)' in html # DARK_THEME foreground assert 'dark' in html + # Scene background is also painted via the WebGL clear color. + assert 'renderer.setClearColor' in html + + +class TestSharedEmbedMode: + def test_shared_omits_per_scene_importmap(self): + from easydiffraction.utils.environment import FigureEmbedMode + + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset({'atoms'}), + dark=False, + mode=FigureEmbedMode.SHARED, + ) + assert 'type="importmap"' not in html + # Bare specifiers remain; the page-level import map resolves them. + assert "from 'three'" in html + + def test_standalone_keeps_inline_importmap(self): + from easydiffraction.utils.environment import FigureEmbedMode + + html = ThreeJsStructureRenderer().render( + _identity_scene(), + features=frozenset({'atoms'}), + offline=True, + dark=False, + mode=FigureEmbedMode.STANDALONE, + ) + assert 'type="importmap"' in html diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 08df9477d..cf016324f 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -877,8 +877,8 @@ def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(mon assert measured_trace.legendrank == 10 assert max_posterior_trace.legendrank == 20 assert max_posterior_trace.line.dash == POSTERIOR_POINT_ESTIMATE_LINE_DASH - assert fig.layout.legend.x == 1.0 - assert fig.layout.legend.y == 1.0 + assert fig.layout.legend.x == 0.99 + assert fig.layout.legend.y == 0.99 assert fig.layout.legend.bgcolor == PlotlyPlotter._legend_background_color() assert fig.layout.margin.r == 30 assert fig.layout.margin.t == 40 diff --git a/tests/unit/easydiffraction/display/test_theme.py b/tests/unit/easydiffraction/display/test_theme.py index e1ce1b99e..3a2b3288d 100644 --- a/tests/unit/easydiffraction/display/test_theme.py +++ b/tests/unit/easydiffraction/display/test_theme.py @@ -24,3 +24,23 @@ def test_display_theme_colors_for_template_maps_plotly_templates(): assert theme.display_theme_colors_for_template('plotly_white') is theme.LIGHT_THEME_COLORS assert theme.display_theme_colors_for_template('plotly_dark') is theme.DARK_THEME_COLORS assert theme.display_theme_colors_for_template('custom') is None + + +def test_plot_backgrounds_opaque_and_paper_transparent(): + import easydiffraction.display.theme as theme + + # Inside the axes rectangle is opaque; the figure paper stays + # transparent so charts blend into the host page. + assert theme.LIGHT_BACKGROUND_COLOR == '#ffffff' + assert theme.DARK_BACKGROUND_COLOR == '#212121' + assert theme.PAPER_BACKGROUND_COLOR == 'rgba(0, 0, 0, 0)' + + +def test_hex_to_rgb_expands_short_and_full_forms(): + from easydiffraction.display.theme import hex_to_rgb + + assert hex_to_rgb('#ffffff') == (255, 255, 255) + assert hex_to_rgb('#111111') == (17, 17, 17) + assert hex_to_rgb('#e6e8ee') == (230, 232, 238) + assert hex_to_rgb('#fff') == (255, 255, 255) + assert hex_to_rgb('#111') == (17, 17, 17) diff --git a/tests/unit/easydiffraction/report/test_data_context.py b/tests/unit/easydiffraction/report/test_data_context.py index ff34c0be8..fe2e2c8fe 100644 --- a/tests/unit/easydiffraction/report/test_data_context.py +++ b/tests/unit/easydiffraction/report/test_data_context.py @@ -476,3 +476,12 @@ def test_report_descriptor_rows_preserve_mixed_mathjax_label_text(): assert rows[0]['html_label'] == r'\(2\theta\) offset' assert rows[0]['html_units'] == r'\(\mathrm{deg}\)' + + +def test_plain_unit_text_renders_squared_degrees_with_superscript(): + from easydiffraction.report.data_context import _plain_unit_text + + assert _plain_unit_text('degrees_squared') == 'deg²' + assert _plain_unit_text('deg^2') == 'deg²' + assert _plain_unit_text('deg²') == 'deg²' + assert _plain_unit_text('degrees') == 'deg' diff --git a/tests/unit/easydiffraction/report/test_html_renderer.py b/tests/unit/easydiffraction/report/test_html_renderer.py index 4a965055a..039d86c85 100644 --- a/tests/unit/easydiffraction/report/test_html_renderer.py +++ b/tests/unit/easydiffraction/report/test_html_renderer.py @@ -363,3 +363,24 @@ def test_render_html_report_uses_plotly_fit_style_order(): assert '"color":"rgb(31, 119, 180)"' in html assert '"name":"Bragg peaks: phase-a"' in html assert '"yaxis3"' in html + + +def test_report_figure_html_ignores_shared_env(monkeypatch): + import plotly.graph_objects as go + + from easydiffraction.report.html_renderer import _figure_html + from easydiffraction.report.style import report_style_context + + # Even with the docs SHARED env set, reports stay STANDALONE (eager, + # self-contained) because the report path passes the mode explicitly. + monkeypatch.setenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', 'shared') + fig = go.Figure(go.Scatter(x=[1.0, 2.0, 3.0], y=[4.0, 5.0, 6.0])) + + html = _figure_html( + fig, + include_plotlyjs=True, + report_style=report_style_context(), + ) + + assert 'data-ed-figure' not in html + assert 'plotly-graph-div' in html or 'newPlot' in html diff --git a/tests/unit/easydiffraction/report/test_style.py b/tests/unit/easydiffraction/report/test_style.py index 826789c9c..7e97f95ff 100644 --- a/tests/unit/easydiffraction/report/test_style.py +++ b/tests/unit/easydiffraction/report/test_style.py @@ -11,8 +11,8 @@ def test_report_style_context_exposes_hex_and_rgb_values(): assert context['axis_hex'] == '#bec7d0' assert context['axis_rgb'] == '190,199,208' - assert context['grid_hex'] == '#e0e0e0' - assert context['grid_rgb'] == '224,224,224' + assert context['grid_hex'] == '#d3d3d3' + assert context['grid_rgb'] == '211,211,211' assert context['chart_grid_rgb'] == '235,240,248' assert context['subtitle'] == 'EasyDiffraction Report' assert 'PT Sans' in context['html_font_family'] diff --git a/tests/unit/easydiffraction/utils/test_environment.py b/tests/unit/easydiffraction/utils/test_environment.py index fab45e723..d74d40025 100644 --- a/tests/unit/easydiffraction/utils/test_environment.py +++ b/tests/unit/easydiffraction/utils/test_environment.py @@ -125,3 +125,41 @@ def test_create_artifact_temp_dir_uses_tutorial_fallback(self, monkeypatch, tmp_ assert created_dir.is_dir() assert created_dir.parent == repo_root / 'tmp' / 'tutorials' + + +class TestResolveFigureEmbedMode: + def test_unset_defaults_to_inline(self, monkeypatch): + from easydiffraction.utils.environment import FigureEmbedMode + from easydiffraction.utils.environment import resolve_figure_embed_mode + + monkeypatch.delenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', raising=False) + assert resolve_figure_embed_mode() is FigureEmbedMode.INLINE + + def test_blank_defaults_to_inline(self, monkeypatch): + from easydiffraction.utils.environment import FigureEmbedMode + from easydiffraction.utils.environment import resolve_figure_embed_mode + + monkeypatch.setenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', ' ') + assert resolve_figure_embed_mode() is FigureEmbedMode.INLINE + + def test_shared_and_standalone_case_insensitive(self, monkeypatch): + from easydiffraction.utils.environment import FigureEmbedMode + from easydiffraction.utils.environment import resolve_figure_embed_mode + + monkeypatch.setenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', 'shared') + assert resolve_figure_embed_mode() is FigureEmbedMode.SHARED + monkeypatch.setenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', 'STANDALONE') + assert resolve_figure_embed_mode() is FigureEmbedMode.STANDALONE + + def test_unknown_value_raises_with_details(self, monkeypatch): + import pytest + + from easydiffraction.utils.environment import resolve_figure_embed_mode + + monkeypatch.setenv('EASYDIFFRACTION_FIGURE_EMBED_MODE', 'bogus') + with pytest.raises(ValueError, match='bogus') as exc_info: + resolve_figure_embed_mode() + message = str(exc_info.value) + assert 'bogus' in message + for mode in ('inline', 'shared', 'standalone'): + assert mode in message diff --git a/tests/unit/tools/test_bump_vendored_js.py b/tests/unit/tools/test_bump_vendored_js.py new file mode 100644 index 000000000..242e5c553 --- /dev/null +++ b/tests/unit/tools/test_bump_vendored_js.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for tools/bump_vendored_js.py drift detection (no network).""" + +from __future__ import annotations + +import hashlib +import importlib.util +import sys +from pathlib import Path + + +def _load_bump(): + repo_root = Path(__file__).resolve().parents[3] + module_path = repo_root / 'tools' / 'bump_vendored_js.py' + spec = importlib.util.spec_from_file_location('bump_vendored_js', module_path) + module = importlib.util.module_from_spec(spec) + # Register before exec so the module's dataclasses can resolve their + # own module via sys.modules. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _runtime(bump, dest_dir, content): + asset = bump.VendoredAsset( + 'a.js', + 'https://example.invalid/a.js', + hashlib.sha256(content).hexdigest(), + ) + return bump.VendoredRuntime( + name='Example', + package='example', + version='1.0.0', + dest_dir=dest_dir, + licence='MIT — Example.', + assets=(asset,), + ) + + +def test_check_runtime_flags_missing_then_passes_then_detects_drift(tmp_path): + bump = _load_bump() + content = b'console.log("hi");\n' + runtime = _runtime(bump, tmp_path, content) + + # Missing asset -> drift reported. + assert bump._check_runtime(runtime) + + # Correct asset + regenerated licence -> clean. + (tmp_path / 'a.js').write_bytes(content) + (tmp_path / 'LICENSES.md').write_text(bump._license_text(runtime), encoding='utf-8') + assert bump._check_runtime(runtime) == [] + + # Tampered asset -> hash drift. + (tmp_path / 'a.js').write_bytes(b'tampered\n') + problems = bump._check_runtime(runtime) + assert any('hash drift' in problem for problem in problems) + + # Restore asset, tamper licence -> licence drift. + (tmp_path / 'a.js').write_bytes(content) + (tmp_path / 'LICENSES.md').write_text('stale\n', encoding='utf-8') + problems = bump._check_runtime(runtime) + assert any('license drift' in problem for problem in problems) diff --git a/tools/bump_vendored_js.py b/tools/bump_vendored_js.py new file mode 100644 index 000000000..b62ae11a4 --- /dev/null +++ b/tools/bump_vendored_js.py @@ -0,0 +1,270 @@ +""" +Bump or verify the vendored JavaScript runtimes. + +Fetches the pinned Plotly and Three.js assets from jsDelivr with +integrity checks, writes them into their canonical vendor folders, and +regenerates each folder's ``LICENSES.md``. With ``--check`` it re-hashes +the already-vendored files against the pinned table and writes nothing +(a CI drift guard); no network access is used in that mode. + +The pinned table below is the single source of truth for the vendored +runtime versions. To bump a runtime: change its ``version``, the asset +URLs, and the expected ``sha256`` hashes, then run +``pixi run vendor-update-js``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +import pooch + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_CACHE_DIR = Path.home() / '.cache' / 'easydiffraction-vendored-js' + + +@dataclass(frozen=True) +class VendoredAsset: + """One vendored file: its name, source URL, and expected hash.""" + + filename: str + url: str + sha256: str + + +@dataclass(frozen=True) +class VendoredRuntime: + """A pinned runtime vendored as one or more files in one folder.""" + + name: str + package: str + version: str + dest_dir: Path + licence: str + assets: tuple[VendoredAsset, ...] + + +THREEJS = VendoredRuntime( + name='Three.js', + package='three', + version='0.160.0', + dest_dir=Path('src/easydiffraction/display/structure/renderers/vendor/threejs'), + licence=( + 'MIT — Copyright © 2010-2024 three.js authors. ' + 'See `https://github.com/mrdoob/three.js/blob/dev/LICENSE`.' + ), + assets=( + VendoredAsset( + 'three.module.js', + 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js', + '76dea8151bc9352aef3528b4262e249b2604f62543828328db978d060d61a495', + ), + VendoredAsset( + 'OrbitControls.js', + 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js', + '5a44a9e86a2a0fb11933eed69bc2cd33c76a496854c1aed6ed776efa87d7b064', + ), + VendoredAsset( + 'CSS2DRenderer.js', + 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/renderers/CSS2DRenderer.js', + 'a4f0f79184c043f6b9d2654d8ba051e49a7d631d34e8f437c1804798a68c379f', + ), + ), +) + +PLOTLY = VendoredRuntime( + name='Plotly.js (cartesian bundle)', + package='plotly.js', + version='3.5.0', + dest_dir=Path('docs/docs/assets/javascripts/vendor/plotly'), + licence=( + 'MIT — Copyright 2012-2026 Plotly, Inc. ' + 'See `https://github.com/plotly/plotly.js/blob/master/LICENSE`.' + ), + assets=( + VendoredAsset( + 'plotly-cartesian.min.js', + 'https://cdn.jsdelivr.net/npm/plotly.js@3.5.0/dist/plotly-cartesian.min.js', + '65248e65cd56530272d19ff7ef12cb849228f4464001f3f67cbb2b95adb22443', + ), + ), +) + +RUNTIMES: tuple[VendoredRuntime, ...] = (THREEJS, PLOTLY) + + +def _sha256(path: Path) -> str: + """ + Return the SHA-256 hex digest of a file. + + Parameters + ---------- + path : Path + File to hash. + + Returns + ------- + str + The hex digest. + """ + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _license_text(runtime: VendoredRuntime) -> str: + """ + Render the ``LICENSES.md`` content for a vendored runtime. + + Parameters + ---------- + runtime : VendoredRuntime + The runtime whose vendored files to document. + + Returns + ------- + str + The full markdown content. + """ + rows = '\n'.join(f'| `{asset.filename}` | `{asset.url}` |' for asset in runtime.assets) + return ( + f'# Vendored {runtime.name}\n\n' + f'Pinned, bundled snapshot fetched verbatim from jsDelivr ' + f'(npm `{runtime.package}@{runtime.version}`). Generated by ' + f'`tools/bump_vendored_js.py`; do not edit by hand — replace only ' + f'by re-fetching the pinned version with `pixi run vendor-update-js`.\n\n' + f'| File | Source URL |\n| --- | --- |\n{rows}\n\n' + f'**Version:** {runtime.version}.\n\n' + f'**Licence:** {runtime.licence}\n\n' + f'These are upstream snapshots, not project-owned code: they are ' + f'excluded from linting, formatting, coverage, and test-structure ' + f'mirroring in `pyproject.toml`.\n' + ) + + +def _bump_runtime(runtime: VendoredRuntime) -> None: + """ + Fetch a runtime's assets and regenerate its ``LICENSES.md``. + + Parameters + ---------- + runtime : VendoredRuntime + The runtime to fetch and write. + """ + dest_dir = _REPO_ROOT / runtime.dest_dir + dest_dir.mkdir(parents=True, exist_ok=True) + for asset in runtime.assets: + fetched = pooch.retrieve( + url=asset.url, + known_hash=f'sha256:{asset.sha256}', + fname=asset.filename, + path=_CACHE_DIR, + ) + shutil.copy2(fetched, dest_dir / asset.filename) + print(f' wrote {runtime.dest_dir / asset.filename}') + (dest_dir / 'LICENSES.md').write_text(_license_text(runtime), encoding='utf-8') + print(f' wrote {runtime.dest_dir / "LICENSES.md"}') + + +def _check_runtime(runtime: VendoredRuntime) -> list[str]: + """ + Return drift messages for a runtime's vendored files and license. + + Checks both the asset hashes and that ``LICENSES.md`` exists and + matches the regenerated text from the pinned table. + + Parameters + ---------- + runtime : VendoredRuntime + The runtime to verify. + + Returns + ------- + list[str] + One message per missing or mismatched file; empty when clean. + """ + problems: list[str] = [] + dest_dir = _REPO_ROOT / runtime.dest_dir + for asset in runtime.assets: + path = dest_dir / asset.filename + if not path.is_file(): + problems.append(f'missing: {runtime.dest_dir / asset.filename}') + continue + actual = _sha256(path) + if actual != asset.sha256: + problems.append( + f'hash drift: {runtime.dest_dir / asset.filename} ' + f'(expected {asset.sha256}, actual {actual})' + ) + license_path = dest_dir / 'LICENSES.md' + if not license_path.is_file(): + problems.append(f'missing: {runtime.dest_dir / "LICENSES.md"}') + elif license_path.read_text(encoding='utf-8') != _license_text(runtime): + problems.append( + f'license drift: {runtime.dest_dir / "LICENSES.md"} ' + f'(does not match the pinned table; run vendor-update-js)' + ) + return problems + + +def bump() -> int: + """ + Fetch and write every pinned runtime. + + Returns + ------- + int + Process exit code (always ``0``; integrity failures raise). + """ + for runtime in RUNTIMES: + print(f'{runtime.name} {runtime.version}:') + _bump_runtime(runtime) + print('Done. Stage and commit the refreshed vendored files.') + return 0 + + +def check() -> int: + """ + Verify vendored files match the pinned hashes. + + Returns + ------- + int + ``0`` when every file matches, ``1`` on any drift. + """ + problems: list[str] = [] + for runtime in RUNTIMES: + problems.extend(_check_runtime(runtime)) + if problems: + print('Vendored JS drift detected:') + for problem in problems: + print(f' {problem}') + return 1 + print('Vendored JS matches the pinned table.') + return 0 + + +def main() -> int: + """ + Parse arguments and run the bump or check workflow. + + Returns + ------- + int + Process exit code. + """ + parser = argparse.ArgumentParser(description='Bump or verify vendored JS runtimes.') + parser.add_argument( + '--check', + action='store_true', + help='Verify vendored files against the pinned hashes (no network, no writes).', + ) + args = parser.parse_args() + return check() if args.check else bump() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/sync_docs_vendored_js.py b/tools/sync_docs_vendored_js.py new file mode 100644 index 000000000..ffd7ab207 --- /dev/null +++ b/tools/sync_docs_vendored_js.py @@ -0,0 +1,52 @@ +""" +Sync the canonical vendored Three.js into the docs assets. + +MkDocs can only serve files under ``docs/docs``, so the canonical +Three.js snapshot — which ships in the wheel from ``src/`` — is copied +into ``docs/docs/assets/javascripts/vendor/threejs/`` for the site to +serve. That docs copy is generated (git-ignored); the single source of +truth is ``src/``. Plotly needs no sync: its docs-only bundle already +lives under ``docs/docs/assets``. + +Run automatically before ``mkdocs build``/``serve`` via the +``docs-sync-vendored-js`` pixi task; the asset names come from the same +pinned table as ``tools/bump_vendored_js.py``. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from bump_vendored_js import THREEJS + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_DOCS_VENDOR_DIR = Path('docs/docs/assets/javascripts/vendor/threejs') + + +def sync() -> int: + """ + Copy the canonical Three.js files into the docs assets. + + Returns + ------- + int + ``0`` on success; ``1`` if a canonical source file is missing. + """ + src_dir = _REPO_ROOT / THREEJS.dest_dir + dest_dir = _REPO_ROOT / _DOCS_VENDOR_DIR + dest_dir.mkdir(parents=True, exist_ok=True) + for asset in THREEJS.assets: + source = src_dir / asset.filename + if not source.is_file(): + print(f'missing canonical source: {THREEJS.dest_dir / asset.filename}') + print('Run `pixi run vendor-update-js` first.') + return 1 + shutil.copy2(source, dest_dir / asset.filename) + print(f' synced {_DOCS_VENDOR_DIR / asset.filename}') + return 0 + + +if __name__ == '__main__': + sys.exit(sync()) From 4d9e31547b5539570845919a52c3059cacfc2b37 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 3 Jun 2026 16:39:15 +0200 Subject: [PATCH 10/12] Detect Wyckoff positions automatically from coordinates (#189) * Add space-group-database canonical-templates phase * Refine canonical-templates phase to re-source from cryspy * Refine canonical-templates CT1 to fix generator and re-run * Settle canonical-templates phase on cctbx-free post-process * Canonicalize space-group coords_xyz templates * Assert canonical coords_xyz in packaged DB check * Record canonical coords_xyz invariant and provenance * Reach canonical-templates Phase 1 review gate * Keep pattern-plot top and bottom panels at fixed height * Test fixed pattern-plot panel heights across row layouts * Inline composite row-height max() and trim docstring * Keep TeX report panels fixed when rows are hidden * Store full canonical Wyckoff orbits via exact check * Document two-stage canonical rebuild as invariant step * Build full Wyckoff orbits from cryspy primitive and centering * Fix plan references to obsolete generator coords source * Validate canonical coords_xyz and coupled constraints * Note plotting-module docstring fix for Phase 2 review * Mark Phase 2 verification complete * Assert R-3m special positions stay on-site after fit * Confirm wyckoff letter detection ADR gate * Add Wyckoff orbit detection to crystallography module * Record free-param-solving Wyckoff snap decision for P1.6 * Add derived space group Wyckoff category * Wire derived Wyckoff table into Structure * Add read-only multiplicity to AtomSite * Derive allowed Wyckoff letters from the space group * Add Wyckoff coordinate snap helper to crystallography module * Detect and track Wyckoff letters in the update flow * Read multiplicity from the model in the cryspy calculator * Serialize Wyckoff multiplicity and report Wyckoff table * Promote wyckoff-letter-detection ADR and close issue #51 * Reach Phase 1 review gate * Preserve explicit Wyckoff letter until a later edit * Build space-group Wyckoff id from multiplicity and letter * Rebuild Wyckoff index and parent links on replace * Reject item assignment and deletion on Wyckoff collection * Resolve None-coord-code groups in Wyckoff constraint helper * Sync top-level plan status with completed Phase 1 * Raise ValueError for Wyckoff read-only mutation per ADR * Remove wyckoff_letter from tutorial examples * Apply pixi run fix auto-fixes * Resolve ruff lint findings in Wyckoff Phase 1 code * Update tests for Wyckoff detection behavior changes * Add tests for derived space_group_wyckoff category * Add detection and lookup tests for Wyckoff crystallography * Assert canonical Wyckoff representative templates in data * Add atom-site Wyckoff detection behavior tests * Add tutorial-corpus Wyckoff detection regression * Satisfy pydoclint and ruff for Wyckoff Phase 2 tests * Fix ed-13 Si to the 8a diamond position * Regenerate tutorial notebooks after Wyckoff letter removal * Restore tutorial-corpus coverage with explicit ground-truth table * Match Fd-3m corpus ground truth to corrected ed-13 Si site * Mark Phase 2 verification complete in plan --- .../dev/adrs/accepted/space-group-database.md | 96 ++++- .../wyckoff-letter-detection.md | 2 +- docs/dev/adrs/index.md | 2 +- docs/dev/issues/closed.md | 11 + docs/dev/issues/open.md | 19 - docs/dev/package-structure/full.md | 9 + docs/dev/package-structure/short.md | 4 + docs/dev/plans/space-group-database.md | 252 ++++++++++++- docs/dev/plans/wyckoff-letter-detection.md | 128 +++++-- docs/docs/tutorials/ed-10.ipynb | 1 - docs/docs/tutorials/ed-10.py | 1 - docs/docs/tutorials/ed-11.ipynb | 1 - docs/docs/tutorials/ed-11.py | 1 - docs/docs/tutorials/ed-12.ipynb | 2 - docs/docs/tutorials/ed-12.py | 2 - docs/docs/tutorials/ed-13.ipynb | 20 +- docs/docs/tutorials/ed-13.py | 20 +- docs/docs/tutorials/ed-16.ipynb | 1 - docs/docs/tutorials/ed-16.py | 1 - docs/docs/tutorials/ed-17.ipynb | 6 - docs/docs/tutorials/ed-17.py | 6 - docs/docs/tutorials/ed-2.ipynb | 4 - docs/docs/tutorials/ed-2.py | 4 - docs/docs/tutorials/ed-20.ipynb | 2 - docs/docs/tutorials/ed-20.py | 2 - docs/docs/tutorials/ed-3.ipynb | 4 - docs/docs/tutorials/ed-3.py | 4 - docs/docs/tutorials/ed-4.ipynb | 5 - docs/docs/tutorials/ed-4.py | 5 - docs/docs/tutorials/ed-5.ipynb | 6 - docs/docs/tutorials/ed-5.py | 6 - docs/docs/tutorials/ed-6.ipynb | 5 - docs/docs/tutorials/ed-6.py | 5 - docs/docs/tutorials/ed-8.ipynb | 6 - docs/docs/tutorials/ed-8.py | 6 - docs/docs/tutorials/ed-9.ipynb | 5 - docs/docs/tutorials/ed-9.py | 5 - .../analysis/calculators/cryspy.py | 27 +- src/easydiffraction/analysis/fitting.py | 7 +- src/easydiffraction/core/validation.py | 30 ++ .../crystallography/__init__.py | 4 + .../crystallography/crystallography.py | 352 +++++++++++++++++- .../crystallography/space_groups.json.gz | Bin 117210 -> 113362 bytes .../categories/atom_sites/default.py | 276 +++++++++++--- .../space_group_wyckoff/__init__.py | 9 + .../categories/space_group_wyckoff/default.py | 206 ++++++++++ .../categories/space_group_wyckoff/factory.py | 19 + .../datablocks/structure/item/base.py | 26 ++ .../display/plotters/plotly.py | 55 ++- src/easydiffraction/io/cif/iucr_writer.py | 51 +++ src/easydiffraction/report/fit_plot.py | 67 ++-- .../test_wyckoff_tutorial_corpus.py | 166 +++++++++ ..._powder-diffraction_constant-wavelength.py | 14 + .../analysis/calculators/test_cryspy.py | 5 + .../test_crystallography_wyckoff.py | 123 +++++- .../crystallography/test_space_groups.py | 55 +++ .../structure/categories/test_atom_sites.py | 161 +++++++- .../categories/test_space_group_wyckoff.py | 136 +++++++ .../display/plotters/test_plotly.py | 67 +++- .../easydiffraction/report/test_fit_plot.py | 40 ++ tools/check_packaged_db.py | 23 +- 61 files changed, 2246 insertions(+), 332 deletions(-) rename docs/dev/adrs/{suggestions => accepted}/wyckoff-letter-detection.md (99%) create mode 100644 src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/__init__.py create mode 100644 src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py create mode 100644 src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/factory.py create mode 100644 tests/functional/test_wyckoff_tutorial_corpus.py create mode 100644 tests/unit/easydiffraction/datablocks/structure/categories/test_space_group_wyckoff.py diff --git a/docs/dev/adrs/accepted/space-group-database.md b/docs/dev/adrs/accepted/space-group-database.md index 74083fd76..fdaf8e1f1 100644 --- a/docs/dev/adrs/accepted/space-group-database.md +++ b/docs/dev/adrs/accepted/space-group-database.md @@ -8,10 +8,10 @@ Structure model. > This ADR follows [`AGENTS.md`](../../../../AGENTS.md). It was a > prerequisite for -> [`wyckoff-letter-detection.md`](../suggestions/wyckoff-letter-detection.md): -> Wyckoff detection can only resolve letters for space groups present in -> the bundled table, which this ADR's implementation completed for all -> 230 groups. +> [`wyckoff-letter-detection.md`](wyckoff-letter-detection.md): Wyckoff +> detection can only resolve letters for space groups present in the +> bundled table, which this ADR's implementation completed for all 230 +> groups. ## Context @@ -103,8 +103,7 @@ Coordinates and operators stay **strings** (e.g. `'(x,1/2,0)'`, `sympify`) in `crystallography.py` and to keep the file JSON-native (§2). Triclinic no-setting groups keep the `None` coordinate code, as today (see the `''`→`None` normalisation in -[`wyckoff-letter-detection.md`](../suggestions/wyckoff-letter-detection.md) -§2). +[`wyckoff-letter-detection.md`](wyckoff-letter-detection.md) §2). **Query surface preserved.** On disk the JSON is a list of setting records, each carrying the canonical `IT_number` and @@ -286,10 +285,9 @@ coordinate-system code": EasyDiffraction's `SpaceGroup` category uses the empty string `''`, while the table key uses `None`. The database keeps `(1, None)` and `(2, None)`; callers normalise `''` to `None` at lookup boundaries, as specified in -[`wyckoff-letter-detection.md`](../suggestions/wyckoff-letter-detection.md). -This is the least surprising solution because it keeps "no setting" -distinct from any real coordinate-code string without inventing a -sentinel value. +[`wyckoff-letter-detection.md`](wyckoff-letter-detection.md). This is +the least surprising solution because it keeps "no setting" distinct +from any real coordinate-code string without inventing a sentinel value. ### 8. The database file is generated, not hand-edited @@ -297,6 +295,36 @@ sentinel value. through the curation overrides and a regeneration run, keeping the file and the documented decisions in sync. +### 9. Canonical ITA `coords_xyz` (no operator form) + +Every Wyckoff `coords_xyz` template is stored in **canonical +International Tables parametric form** — each component a signed single +free variable (or an integer-coefficient combination such as `x-y`) plus +an optional rational constant, never a fractional coefficient on a +variable. cctbx's `unique_ops().as_xyz()` (the generator's raw output) +emits **operator form** (e.g. `1/2*x-1/2*y`) for coupled special +positions, which silently breaks +`crystallography._fract_constrained_flags` so a refined special-position +coordinate drifts off its symmetry site. A Wyckoff orbit is a list of +**distinct point-functions** (one per symmetry-equivalent site), and the +DB stores the **full** (centered) orbit — `coords_xyz` length equals the +ITA multiplicity. cryspy lists only the **primitive** orbit, so the full +orbit is built by **expanding** each cryspy primitive element over the +group's centering translations (the identity-rotation symops): +`full = {primitive element + centering vector}`, yielding exactly +`multiplicity` distinct canonical templates. Every replacement is +verified **exactly**: `len == multiplicity` and +`len(set) == multiplicity` (a proper full orbit with distinct elements — +no collapsed duplicates); no operator form and no fractional +coefficient; the representative's `_fract_constrained_flags` free-axis +count equals the manifold's rank (rejecting non-minimal spellings such +as `(x-y,-x+y,z)`); and parametrization-independent geometric +equivalence to the cctbx orbit (every element on a cctbx manifold, every +cctbx manifold covered). The no-operator-form and no-duplicate +invariants are enforced by the post-process before it writes, in the +unit tests, and (for operator form) by `tools/check_packaged_db.py`, +which rejects any operator-form template in the packaged wheel. + ## Consequences ### Positive @@ -346,9 +374,9 @@ and the documented decisions in sync. early when `coord_code is None` and `_get_general_position_ops()` indexes the raw key, so they need the `''`→`None` normalisation defined in - [`wyckoff-letter-detection.md`](../suggestions/wyckoff-letter-detection.md) - §2 (which also updates these call sites). This ADR delivers the data; - that ADR delivers the `None`-code consumer handling. + [`wyckoff-letter-detection.md`](wyckoff-letter-detection.md) §2 (which + also updates these call sites). This ADR delivers the data; that ADR + delivers the `None`-code consumer handling. ## Alternatives Considered @@ -409,6 +437,32 @@ pixi exec --spec cctbx --spec gemmi --spec sympy --spec pyyaml \ --print-summary ``` +**Canonical-`coords_xyz` correction (§9) — mandatory second stage.** The +rebuild has **two mandatory stages**, and the generator run above is +only the first. The generator emits cctbx operator-form `coords_xyz` for +coupled special positions and **cannot** emit canonical form itself: +cctbx always produces operator form, and the canonical ITA spelling +lives in cryspy's `wyckoff.dat`. The generator alone therefore does +**not** produce a shippable database. It **must** be followed by the +canonicalization post-process, which is the **invariant-enforcing step** +— it re-sources canonical ITA `coords_xyz` from cryspy's `wyckoff.dat` +and refuses to write unless every template is canonical (no operator +form, no fractional coefficient): + +```bash +python tmp/space-groups/helper-tools/canonicalize_coords.py --write +``` + +It rebuilds each of the 288 coupled positions' **full** orbit by +**expanding** the cryspy primitive orbit over the group's centering +translations — `coords_xyz` length equals the multiplicity and every +element is distinct. It changes only `coords_xyz`, verifies every +replacement **exactly** (distinct full orbit, canonical spelling, a +`_fract_constrained_flags` rank check, and parametrization-independent +geometric equivalence to the cctbx orbit), and asserts no operator-form +and no duplicate template remains. The `space_groups.json.gz` SHA-256 +below is **after** this correction. + Build environment: - **cctbx** from conda-forge: @@ -423,10 +477,14 @@ Build environment: Generated and curation artifacts: -- `src/easydiffraction/crystallography/space_groups.json.gz`: - `30f0051c669712ab34d991e60223c5e29264fc033b2ab03392cc01465ceba926` +- `src/easydiffraction/crystallography/space_groups.json.gz` (after the + §9 canonical-`coords_xyz` correction): + `390f0e9d0ebe27a52ee5680a1bc686123ba84c8751302fed4dee4dfaf7edf7b4` - `tmp/space-groups/helper-tools/generate_space_groups.py`: - `bf10dcfbcf9e60485037ddabc65425e61f746ad9649cd3ccc67376dd6aae241a` + `3aa5f03cd1a69bdfe0a280158c9343b65d5eaa4d75a6d58f2606fb5fbe3df83d` +- `tmp/space-groups/helper-tools/canonicalize_coords.py` (§9 + canonical-`coords_xyz` post-process): + `8f2e94b130481d2a11de057fe200d8e5fd5d3d5eec7cdd39f5d4afd13f5cb8f2` - `docs/dev/adrs/accepted/space-group-database/space_groups_overrides.yaml`: `7077eec25d0f3b852dd7096a24dc7ac438467f9cb594f91a65ce10cda0e0722a` - `tmp/space-groups/extracted-comparison/disagreements.md`: @@ -511,8 +569,8 @@ respectively. ## Related ADRs -- [`wyckoff-letter-detection.md`](../suggestions/wyckoff-letter-detection.md) - — the dependent feature; its `''`→`None` coordinate-code normalisation - and its "unsupported group" handling both build on this database. +- [`wyckoff-letter-detection.md`](wyckoff-letter-detection.md) — the + dependent feature; its `''`→`None` coordinate-code normalisation and + its "unsupported group" handling both build on this database. - [`iucr-cif-tag-alignment.md`](../accepted/iucr-cif-tag-alignment.md) — consumes space-group and Wyckoff data on export. diff --git a/docs/dev/adrs/suggestions/wyckoff-letter-detection.md b/docs/dev/adrs/accepted/wyckoff-letter-detection.md similarity index 99% rename from docs/dev/adrs/suggestions/wyckoff-letter-detection.md rename to docs/dev/adrs/accepted/wyckoff-letter-detection.md index a838598e1..47df620bc 100644 --- a/docs/dev/adrs/suggestions/wyckoff-letter-detection.md +++ b/docs/dev/adrs/accepted/wyckoff-letter-detection.md @@ -1,6 +1,6 @@ # ADR: Automatic Wyckoff Position Detection -**Status:** Proposed **Date:** 2026-06-01 +**Status:** Accepted **Date:** 2026-06-01 ## Group diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index f14cb0be0..eea0d1af1 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -46,7 +46,7 @@ folders. | Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) | | Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) | | Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) | -| Structure model | Suggestion | Automatic Wyckoff Position Detection | Detects Wyckoff letter, multiplicity, and site symmetry from space group and coordinates; calculators consume them. | [`wyckoff-letter-detection.md`](suggestions/wyckoff-letter-detection.md) | +| Structure model | Accepted | Automatic Wyckoff Position Detection | Detects Wyckoff letter, multiplicity, and site symmetry from space group and coordinates; calculators consume them. | [`wyckoff-letter-detection.md`](accepted/wyckoff-letter-detection.md) | | Structure model | Accepted | Complete Space-Group Reference Database | One-time build of a complete space_groups.json.gz (all 230 groups) from cctbx, verified against multiple sources. | [`space-group-database.md`](accepted/space-group-database.md) | | User-facing API | Accepted | Crystal Structure 3D Visualization | Adds a renderer-neutral scene model drawn by ASCII and interactive Three.js engines for viewing crystal structures. | [`crysview-structure-visualization.md`](accepted/crysview-structure-visualization.md) | | User-facing API | Accepted | Display UX Facade | Defines `project.display` and `project.rendering` responsibilities and display method names. | [`display-ux.md`](accepted/display-ux.md) | diff --git a/docs/dev/issues/closed.md b/docs/dev/issues/closed.md index c3574c2f2..e6b000055 100644 --- a/docs/dev/issues/closed.md +++ b/docs/dev/issues/closed.md @@ -4,6 +4,17 @@ Issues that have been fully resolved. Kept for historical reference. --- +## 51. Access Space Group from `AtomSites` for Wyckoff Letters + +Closed by the Wyckoff-letter-detection implementation. `AtomSite` now +derives its allowed Wyckoff letters from the parent structure's space +group (via `_resolve_structure_space_group`) instead of a hardcoded +list, and the missing-letter case is handled explicitly: untabulated +space groups leave the Wyckoff letter and multiplicity unset, while +tabulated groups detect and fill them during the update flow. + +--- + ## 103. Make `_sync_engine_from_minimizer_category` Skip-Keys Declarative Closed by the emcee minimizer implementation. Minimizer categories now diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md index d1d400759..89434ef8f 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -996,24 +996,6 @@ generation. --- -## 51. 🟢 Access Space Group from `AtomSites` for Wyckoff Letters - -**Type:** Design - -`AtomSite` needs the current space group to determine allowed Wyckoff -letters but currently returns a hardcoded list. Also, a missing Wyckoff -letter case needs a decision. - -**TODOs:** - -- [default.py](src/easydiffraction/datablocks/structure/categories/atom_sites/default.py#L163) -- [default.py](src/easydiffraction/datablocks/structure/categories/atom_sites/default.py#L179) -- [default.py](src/easydiffraction/datablocks/structure/categories/atom_sites/default.py#L353) - -**Depends on:** nothing. - ---- - ## 52. 🟢 Rename Line-Segment Background `y` to `intensity` **Type:** Naming @@ -1951,7 +1933,6 @@ only render the index column when no explicit id column is present. | 48 | Fix CrysPy TOF instrument default | 🟢 Low | Bug workaround | | 49 | Automate space group CIF name variants | 🟢 Low | Maintainability | | 50 | Clarify `Cell._update` minimizer param | 🟢 Low | Cleanup | -| 51 | Access space group for Wyckoff letters | 🟢 Low | Design | | 52 | Rename line-segment `y` to `intensity` | 🟢 Low | Naming | | 53 | Move `show()` to `CategoryCollection` | 🟢 Low | Maintainability | | 54 | Add `point_id` to excluded regions | 🟢 Low | Completeness | diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index cad676efe..de4e69ac5 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -244,6 +244,7 @@ │ │ ├── 🏷️ class TypeValidator │ │ ├── 🏷️ class RangeValidator │ │ ├── 🏷️ class MembershipValidator +│ │ ├── 🏷️ class PermissiveMembershipValidator │ │ ├── 🏷️ class RegexValidator │ │ └── 🏷️ class AttributeSpec │ └── 📄 variable.py @@ -262,6 +263,7 @@ ├── 📁 crystallography │ ├── 📄 __init__.py │ ├── 📄 crystallography.py +│ │ └── 🏷️ class WyckoffPosition │ └── 📄 space_groups.py ├── 📁 datablocks │ ├── 📁 experiment @@ -464,6 +466,13 @@ │ │ │ │ │ └── 🏷️ class SpaceGroup │ │ │ │ └── 📄 factory.py │ │ │ │ └── 🏷️ class SpaceGroupFactory +│ │ │ ├── 📁 space_group_wyckoff +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ ├── 🏷️ class SpaceGroupWyckoff +│ │ │ │ │ └── 🏷️ class SpaceGroupWyckoffCollection +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class SpaceGroupWyckoffFactory │ │ │ └── 📄 __init__.py │ │ ├── 📁 item │ │ │ ├── 📄 __init__.py diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index 23c0e68b8..61c705dd4 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -222,6 +222,10 @@ │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 default.py │ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 space_group_wyckoff +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py │ │ │ └── 📄 __init__.py │ │ ├── 📁 item │ │ │ ├── 📄 __init__.py diff --git a/docs/dev/plans/space-group-database.md b/docs/dev/plans/space-group-database.md index fe2a4b93f..814b36e0b 100644 --- a/docs/dev/plans/space-group-database.md +++ b/docs/dev/plans/space-group-database.md @@ -26,7 +26,7 @@ This plan owns the ADR [`docs/dev/adrs/accepted/space-group-database.md`](../adrs/accepted/space-group-database.md) (drafted via `/draft-adr`, review cycle closed). It is a **prerequisite** for -[`wyckoff-letter-detection`](../adrs/suggestions/wyckoff-letter-detection.md): +[`wyckoff-letter-detection`](../adrs/accepted/wyckoff-letter-detection.md): this plan delivers the complete data; that feature delivers the `''`→`None` consumer handling so the triclinic groups use it. @@ -272,3 +272,253 @@ flagged rows visible for later International Tables verification. The data now ships as transparent, inspectable JSON instead of an opaque binary pickle. Existing projects load unchanged; structures in the previously-missing groups now get correct symmetry handling. + +--- + +## Follow-up phase: canonical Wyckoff `coords_xyz` templates + +_Added after #187 shipped — a second, standalone cycle of this ADR's +implementation. It is the **prerequisite** that +[`wyckoff-letter-detection`](../adrs/accepted/wyckoff-letter-detection.md) +§10 / Decision 15 depends on and gates on at its P1.0, and it ships on +its **own PR** (separate from #187). When implementing this phase, treat +the `CT` checklist below as the active Phase 1 steps — the #187 Phase 1 +above is complete._ + +### Status (this phase) + +- [x] Phase 1 — Implementation (data + tooling + docs) +- [x] Phase 1 review gate +- [x] Phase 2 — Verification (tests + checks) + +### Problem + +The bundled `space_groups.json.gz` stores cctbx **operator-form** +`coords_xyz` — e.g. R-3m (IT 166) `h` is `(1/2*x-1/2*y,-1/2*x+1/2*y,z)` +— for the coupled special positions (verified: 3826 templates; per the +wyckoff plan's Decision 15, 288 positions across 117 IT numbers). +`_fract_constrained_flags()` (`crystallography.py:221`) decides which +axis is constrained purely by **which variable symbol is absent** from +the representative: canonical `(x,-x,z)` has `y` absent → `fract_y` +constrained → slaved to `-x`; but operator-form `(1/2*x-1/2*y,…)` makes +**both `x` and `y` appear** → `fract_y` is wrongly "free" and the `y=-x` +coupling is lost, so a refined special-position coordinate drifts off +its symmetry site (the `ed-6` fit-3 → fit-4 regression). +`_apply_fract_constraints()` (`:250`) and `_parse_rotation_matrix()` +(`:350`, which does `int(coeff_str)`) share the same expectation. The +constraint **code** is correct; only the **data** spelling is wrong. +**Root cause:** the generator emitted cctbx operator-form `coords_xyz` +for these positions instead of cryspy's canonical form; the ADR's +intended Wyckoff source, cryspy's `wyckoff.dat`, already holds the +canonical ITA form for them (verified — R-3m gives `(x,x,1/2)`, +`(x,-x,1/2)`, …). + +### Decisions (this phase) + +1. **Produce canonical `coords_xyz` via a cctbx-free post-process that + re-sources from cryspy's `wyckoff.dat`.** The root cause is that the + generator's `_extract_wyckoff_positions` built `coords_xyz` from + cctbx (`position.unique_ops().as_xyz()`, operator form) while + cryspy's `wyckoff.dat` — the ADR's intended Wyckoff source — was read + only for count-validation. A Wyckoff orbit is a list of **distinct + point-functions** (one per equivalent site), not a set of manifolds. + A local tool loads the bundled DB and, for each operator-form + position, builds the **full** canonical orbit by **expanding** the + cryspy primitive orbit over the group's centering translations (the + identity-rotation symops): each cryspy primitive element shifted by + each centering vector yields one canonical full-orbit element. This + **preserves the full (centered) orbit** — `coords_xyz` length stays + equal to the multiplicity, matching the #187 baseline — with + **distinct** elements. (cctbx stores the full centered orbit; cryspy + lists only the **primitive** orbit, with centering implicit.) + Canonical form = each component a signed single free variable (or an + integer-coefficient ITA combination such as `x-y`) plus an optional + rational constant — **no fractional coefficient on a variable** — + with each genuine free DOF reduced to one canonical variable so + dependent axes' symbols are absent. _Rejected alternatives: (a) + replacing `coords_xyz` with cryspy's orbit **directly** silently + reduces centered orbits to primitive, breaking the + length-equals-multiplicity invariant (review-1 [Finding 2]); (b) + re-spelling each cctbx element by **column space** collapses distinct + point-functions sharing a manifold into **duplicate** templates + (review-2 [Finding 1]). The centering expansion avoids both. No cctbx + re-run is needed; fixing the generator + full cctbx re-run adds a + heavy install + reproducibility risk for the same cryspy↔cctbx + reconciliation._ +2. **Verify each replacement two ways** (review-1 [P1] plus the CT1 + correctness gap): (a) **exact symbolic orbit equivalence** (`sympy`) + — the cryspy canonical orbit and the cctbx operator-form orbit + describe the same point set as rational affine forms, not merely + agree at sampled parameters; **and** (b) **constraint correctness** — + the canonical representative's free-symbol set produces the right + `_fract_constrained_flags()` (free-DOF count matches the orbit's true + dimensionality; dependent axes constrained). Orbit equivalence alone + is insufficient: a non-minimal form like `(x-y,-x+y,z)` would pass it + yet leave `fract_y` wrongly free. _Rejected alternative — a blind + `sympy` re-parametrise of the existing operator strings — risks + exactly that non-minimal failure mode and re-derives the ITA + convention cryspy already encodes._ +3. **No constraint-code change.** `_fract_constrained_flags()` / + `_apply_fract_constraints()` are correct as-is. +4. **No new dependency, no cctbx.** The post-process uses `cryspy`'s + `wyckoff.dat` + `numpy`/`sympy` (already project deps); `cctbx` is + not used at all (it stays generation-only, relevant only if the + generator is ever fully re-run). +5. **Guard the output:** the canonicalize post-process is the + invariant-enforcing step — it refuses to write unless every template + is canonical and the orbit is distinct — backed by a + `tools/check_packaged_db.py` assertion rejecting operator-form + leakage in the packaged wheel and a unit data-invariant over loaded + `SPACE_GROUPS`. (The generator stays cctbx extraction and cannot + self-assert this; see Decision 1 and CT2.) +6. **Record it in the ADR:** add the canonical-`coords_xyz` invariant as + a decision and update _Build Provenance_ with the new DB SHA-256 and + the transform's SHA-256. + +### Open questions (this phase) + +- If any affected position cannot be canonicalised automatically, fall + back to a maintainer-curated entry in the existing + `space_groups_overrides.yaml` channel and record it. Not expected. + +### Concrete files likely to change (this phase) + +- `src/easydiffraction/crystallography/space_groups.json.gz` — rewritten + with canonical `coords_xyz`; all other fields unchanged. +- `tmp/space-groups/helper-tools/canonicalize_coords.py` — **new**, + local ignored cctbx-free post-process: load DB → orbit-match each + position to cryspy's canonical orbit → verify (mod-1 orbit + membership + `_fract_constrained_flags`) → rewrite `coords_xyz` (all + other fields byte-identical). +- `tmp/space-groups/helper-tools/generate_space_groups.py` — **local, + ignored**: stays cctbx extraction; it **cannot** self-canonicalize + (cctbx always emits operator form, and the canonical spelling + checks + need the project env — see CT2). Its `_extract_wyckoff_positions` + docstring directs to the mandatory `canonicalize_coords.py` second + stage. Not re-run now. +- `docs/dev/adrs/accepted/space-group-database/space_groups_overrides.yaml` + — record any position the orbit match leaves ambiguous (curated vs + International Tables). +- `tools/check_packaged_db.py` — assert no packaged `coords_xyz` is + operator-form. +- `docs/dev/adrs/accepted/space-group-database.md` — canonical-form + invariant decision + updated _Build Provenance_. +- Phase 2 tests: + - `tests/unit/easydiffraction/crystallography/test_space_groups.py` + (or `_coverage.py`) — data invariant: no `SPACE_GROUPS` `coords_xyz` + is operator-form. + - `tests/unit/easydiffraction/crystallography/test_crystallography.py` + (or `_coverage.py`) — coupled-position constraint regression: for + R-3m `h` (`(x,-x,z)`), `_fract_constrained_flags()` marks `fract_y` + constrained and `_apply_fract_constraints()` slaves it to `-fract_x` + after a `fract_x` edit (existing tests cover only all-fixed / + all-free sites). + - `ed-6` functional/script regression: the special-position fit stays + on-site across fit-3 → fit-4. + +### Implementation steps — canonical-templates phase + +Per-step commit discipline as in Phase 1, **with the same deliberate +exception** that `tmp/space-groups/helper-tools/*` are local curation +tooling, not branch deliverables (review-1 [P1]). The local transform +and generator changes are therefore **not their own commits**: each +tracked commit stages only the tracked deliverable it produces, and the +local tools are recorded by SHA-256 in the ADR provenance (CT4). No step +commits only ignored files, and no empty commits. + +- [x] **CT1 — Canonicalise the DB via the cctbx-free post-process.** + Finish the local + `tmp/space-groups/helper-tools/canonicalize_coords.py`: parse + cryspy `wyckoff.dat`, then build each operator-form position's + **full** canonical orbit by **expanding the cryspy primitive orbit + over the group's centering translations** (`coords_xyz` length + equals the multiplicity, all elements **distinct**). Verify per + position, **exactly** — `len(set) == multiplicity` (distinct full + orbit), no operator form, no fractional coefficient, correct + `_fract_constrained_flags()` (free-axis count equals the manifold + rank), and parametrization-independent geometric equivalence to + the cctbx orbit. Curate against International Tables any case the + cryspy match leaves ambiguous, recording it in + `space_groups_overrides.yaml`. Run it to rewrite + `src/easydiffraction/crystallography/space_groups.json.gz` (R-3m + `h` → `(x,-x,z)`, 18-element full orbit; all non-`coords_xyz` + fields byte-identical). The tool is local tooling (recorded by SHA + in CT4); **this commit stages only the regenerated + `space_groups.json.gz`**. Commit: + `Canonicalize space-group coords_xyz templates` +- [x] **CT2 — Make the durable rebuild path two-stage with an + invariant-enforcing post-process (local prep, no commit).** The + generator (`generate_space_groups.py`) runs in a throwaway + cctbx-only env, and cctbx **always** emits operator-form coords + for coupled positions — so the generator cannot itself source + canonical coords or self-assert a no-operator-form invariant (the + canonical spelling lives in cryspy's `wyckoff.dat`, and the + constraint check needs `easydiffraction`, i.e. the project env). + The durable rebuild path is therefore **two mandatory stages**: + (1) the generator (cctbx env), then (2) + `canonicalize_coords.py --write` (project env), which is the + **invariant-enforcing step** — it re-sources canonical ITA coords, + verifies each exactly, and refuses to write unless every template + is canonical (no operator form, no fractional coefficient). The + generator's `_extract_wyckoff_positions` docstring directs to this + mandatory post-process and the ADR _Build Provenance_ documents + both stages (review-1 [Finding 1]). **No re-run now** (CT1 already + produced the canonical DB). Local curation tooling — not + committed; its SHA-256 is recorded in CT4. +- [x] **CT3 — Packaging assertion.** Extend the tracked + `tools/check_packaged_db.py` to assert no packaged `coords_xyz` + template is operator-form (catches future regression at the wheel + layer). Commit: `Assert canonical coords_xyz in packaged DB check` +- [x] **CT4 — ADR invariant + provenance.** Add the + canonical-`coords_xyz` invariant as a decision in + `docs/dev/adrs/accepted/space-group-database.md`, and update its + _Build Provenance_ with the new `space_groups.json.gz` SHA-256, + the `canonicalize_coords.py` transform SHA-256, **and the updated + `generate_space_groups.py` SHA-256** plus the canonical-invariant + step in the rebuild path (review-1 [P2]). Commit: + `Record canonical coords_xyz invariant and provenance` +- [x] **CT5 — Phase 1 review gate.** No code. Mark `[x]`, commit the + checklist update alone, hand off to review. Commit: + `Reach canonical-templates Phase 1 review gate` + +### Phase 2 — Verification (canonical-templates phase) + +Add the tests above, then run (zsh-safe capture): + +```bash +pixi run fix +pixi run test-structure-check > /tmp/easydiffraction-test-structure.log 2>&1; test_structure_exit_code=$?; tail -n 50 /tmp/easydiffraction-test-structure.log; exit $test_structure_exit_code +pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code +pixi run unit-tests > /tmp/easydiffraction-unit.log 2>&1; unit_tests_exit_code=$?; tail -n 100 /tmp/easydiffraction-unit.log; exit $unit_tests_exit_code +pixi run integration-tests > /tmp/easydiffraction-integration.log 2>&1; integration_tests_exit_code=$?; tail -n 100 /tmp/easydiffraction-integration.log; exit $integration_tests_exit_code +pixi run script-tests > /tmp/easydiffraction-script.log 2>&1; script_tests_exit_code=$?; tail -n 100 /tmp/easydiffraction-script.log; exit $script_tests_exit_code +``` + +Then the packaging regression from #187's Phase 2 (build the wheel + +`python tools/check_packaged_db.py dist/*.whl`), which now also +exercises the new operator-form assertion. + +**Verification note (for `/review-impl-2`).** Phase 2's `pixi run fix` +also reformatted docstrings in +`src/easydiffraction/display/plotters/plotly.py` and +`src/easydiffraction/report/fit_plot.py` — **pre-existing** debt from +this branch's plotting commits (`bb817a5fd`, `176e6e682`, `bec2f5e73`), +surfaced by the recently-enabled `format-docstring` hook and required +for `pixi run check` to pass. It is **unrelated** to canonical-templates +and was folded into the verification commit `92c41124b` (authored +manually); drop or move it if this branch is split from the plotting +stream. + +### Suggested Pull Request — canonical-templates fix + +**Title:** Store space-group Wyckoff coordinates in canonical form + +**Description:** A refined atom on certain special positions (sites with +linked coordinates, like `(x, -x, z)`) could drift off its symmetry +position during a fit, because the bundled space-group table stored +those coordinate templates in an internal operator form the +symmetry-constraint code didn't recognise. This change rewrites every +affected template into the standard International Tables form so the +constraints hold, adds guards so the table can't silently regress, and +fixes the related `ed-6` tutorial refinement. It also unblocks automatic +Wyckoff-position detection, which builds on these canonical templates. diff --git a/docs/dev/plans/wyckoff-letter-detection.md b/docs/dev/plans/wyckoff-letter-detection.md index 11b115ec7..2bfcb4bc5 100644 --- a/docs/dev/plans/wyckoff-letter-detection.md +++ b/docs/dev/plans/wyckoff-letter-detection.md @@ -1,20 +1,20 @@ # Plan: Automatic Wyckoff Position Detection This plan follows [`AGENTS.md`](../../../AGENTS.md) and implements the -[`wyckoff-letter-detection`](../adrs/suggestions/wyckoff-letter-detection.md) +[`wyckoff-letter-detection`](../adrs/accepted/wyckoff-letter-detection.md) ADR. No deliberate exception to `AGENTS.md` is taken. ## Status - [x] ADR review gate closed -- [ ] Phase 1 — Implementation (code + docs) -- [ ] Phase 1 review gate -- [ ] Phase 2 — Verification (tests + `pixi` checks) +- [x] Phase 1 — Implementation (code + docs) +- [x] Phase 1 review gate +- [x] Phase 2 — Verification (tests + `pixi` checks) ## ADR This plan implements the -[`wyckoff-letter-detection`](../adrs/suggestions/wyckoff-letter-detection.md) +[`wyckoff-letter-detection`](../adrs/accepted/wyckoff-letter-detection.md) ADR. Earlier ADR review cycles closed at review 10 and then review 16 (adding the derived `space_group_Wyckoff` category and space-group-key re-detection); that text was committed as `0f3bc269c` @@ -249,7 +249,7 @@ before moving to the next step or the Phase 1 review gate**, per The ADR commit + design-phase review/reply cleanup are handled by `/draft-impl-1` Phase A before P1.1. -- [ ] **P1.0 — Verify the ADR gate and the §10 prerequisite.** No code. +- [x] **P1.0 — Verify the ADR gate and the §10 prerequisite.** No code. Ensure `git branch --show-current` is `wyckoff-letter-detection`; if not, stop before editing and ask the user to switch to the target branch outside the shortcut. Confirm the ADR on disk @@ -264,7 +264,7 @@ The ADR commit + design-phase review/reply cleanup are handled by remains, **stop**: the space-group-database prerequisite must land before this plan's detection and snapping can be implemented. Commit: `Confirm wyckoff letter detection ADR gate` -- [ ] **P1.1 — Orbit matcher in the crystallography submodule.** Add to +- [x] **P1.1 — Orbit matcher in the crystallography submodule.** Add to `crystallography.py`: frozen `WyckoffPosition(letter, multiplicity, site_symmetry, coord_template)`, `_WYCKOFF_DETECTION_TOL = 1e-3`, `_normalize_coord_code()`, @@ -276,7 +276,7 @@ The ADR commit + design-phase review/reply cleanup are handled by Export new public names via `crystallography/__init__.py` if public. Commit: `Add Wyckoff orbit detection to crystallography module` -- [ ] **P1.2 — Derived `space_group_wyckoff` category.** Add the +- [x] **P1.2 — Derived `space_group_wyckoff` category.** Add the `space_group_wyckoff` package with a `SpaceGroupWyckoff` item keyed by `id` (`_space_group_Wyckoff.id`) and read-only descriptors for `id`, `letter`, `multiplicity`, `site_symmetry`, @@ -284,7 +284,7 @@ The ADR commit + design-phase review/reply cleanup are handled by mutation methods raising and a private `_replace_from_space_group` rebuild method that creates/adopts rows from `SPACE_GROUPS[key]`. Commit: `Add derived space group Wyckoff category` -- [ ] **P1.3 — Wire `space_group_wyckoff` into `Structure`.** Add it as +- [x] **P1.3 — Wire `space_group_wyckoff` into `Structure`.** Add it as a read-only sibling category on `Structure`, rebuild it when structure categories update so it tracks the current space group, keep it empty for absent groups, and exclude it from project CIF @@ -294,14 +294,14 @@ The ADR commit + design-phase review/reply cleanup are handled by reads `SPACE_GROUPS` through the crystallography helpers rather than depending on this collection. Commit: `Wire derived Wyckoff table into Structure` -- [ ] **P1.4 — Read-only multiplicity + detection mutator on +- [x] **P1.4 — Read-only multiplicity + detection mutator on `AtomSite`.** Add only `multiplicity` as a read-only derived descriptor on `AtomSite` with `CifHandler` for `_atom_site.site_symmetry_multiplicity`, empty form `None`, and no public setter. Add `_set_wyckoff_letter_detected()` modelled on `_set_value_from_minimizer`. Do not add `site_symmetry` to `AtomSite`. Commit: `Add read-only multiplicity to AtomSite` -- [ ] **P1.5 — Dynamic allowed letters + unsupported-group validation.** +- [x] **P1.5 — Dynamic allowed letters + unsupported-group validation.** Make `_wyckoff_letter_allowed_values` return `['', *list(SPACE_GROUPS[key]['Wyckoff_positions'])]` for a supported group and `[]` for an absent one. Add the @@ -314,7 +314,7 @@ The ADR commit + design-phase review/reply cleanup are handled by "needs context validation" marker instead of treating missing context as an unsupported group. Commit: `Derive allowed Wyckoff letters from the space group` -- [ ] **P1.6 — Detection triggers in the atom-site update flow.** In +- [x] **P1.6 — Detection triggers in the atom-site update flow.** In `_update(*, called_by_minimizer=False)`, implement fill-if-empty, re-detect-on-coordinate-change, and re-detect-on-space-group-key change with per-atom coordinate and `(name_hm, coord_code)` @@ -325,21 +325,55 @@ The ADR commit + design-phase review/reply cleanup are handled by stored letter. For supported keys, refresh letter, multiplicity, and selected representative; for unsupported keys, preserve stored letters as unvalidated values, set multiplicity to `None`, skip - constraints, and warn. Use the selected `coord_template` for - snapping and constrained-axis flags. Warn when coordinate or - supported space-group edits move the letter, when a user - letter-set snaps coordinates, and when a same-letter coordinate - edit snaps coordinates. Honour `called_by_minimizer=True`; - populate `multiplicity` from `wyckoff_position_info`. - Site-symmetry display data comes from + constraints, and warn. Snap by **solving the free parameters** — + least-squares project the coordinate onto the selected + `coord_template`'s manifold, then set every axis to that manifold + point — so centering copies and off-canonical-slot representatives + (e.g. 6e `(0,x,0)`) snap correctly; derive constrained-axis flags + from the same representative. This free-parameter-solving snap + **replaces the positional `_apply_fract_constraints` + substitution** (a deliberate deviation from ADR §5's "existing + constraint step", decided during P1.1; reflect it in ADR §5 at the + P1.9 promotion). Warn when coordinate or supported space-group + edits move the letter, when a user letter-set snaps coordinates, + and when a same-letter coordinate edit snaps coordinates. Honour + `called_by_minimizer=True`; populate `multiplicity` from + `wyckoff_position_info`. Site-symmetry display data comes from `structure.space_group_wyckoff`, not from `AtomSite`. Commit: `Detect and track Wyckoff letters in the update flow` -- [ ] **P1.7 — Calculator consumes model multiplicity.** Replace the + + _P1.6 implementation decisions (implemented):_ + - **Snap = slot-aware free-parameter-solving** + (`crystallography.snap_to_wyckoff_template`, already committed): + solve the free params from the **free (refinable) axes**, keep those + axes, and derive the constrained axes. **Not** manifold projection — + that averaged/moved the free axis and fought the minimizer. Handles + off-canonical reps like 6e `(0,x,0)` (keep `fract_y`, set + `fract_x=fract_z=0`); matches the old substitution for canonical + sites, so the fit is unaffected. Per-axis constraint flags are + slot-based (first-occurrence), not symbol-based. + - **Warning gating (per the chosen option):** pass + `called_by_minimizer=True` **only at the per-iteration minimizer + objective** — `analysis/fit_helpers/metrics.py:181` (residual calc; + verify `analysis/fitting.py:382` too) — and **leave** the fit-setup + (`fitting.py:209`) and flush (`analysis.py:174`) sites `False` so + detection still runs there. Gate re-detection **and** the + "adjusted"/"moved-letter" warnings on `not called_by_minimizer`, so + they never fire per fit step. + - **Remaining:** rewrite + `_apply_atomic_coordinates_symmetry_constraints` (per atom: resolve + the `_wyckoff_letter_needs_validation` marker → decide + detect/trigger → snap → set `multiplicity` + constrained flags → + refresh baselines), thread `called_by_minimizer` through + `AtomSites._update`, change the objective call site(s), then + **verify by running `test_fit_neutron_pd_cwl_hs`** and smoke tests. + +- [x] **P1.7 — Calculator consumes model multiplicity.** Replace the `SPACE_GROUPS` lookup in `cryspy._update_atom_multiplicity` with `atom_site.multiplicity.value`; when it is `None`, leave the backend's inferred multiplicity in place. Commit: `Read multiplicity from the model in the cryspy calculator` -- [ ] **P1.8 — CIF and report output.** Ensure project CIF writes +- [x] **P1.8 — CIF and report output.** Ensure project CIF writes `_atom_site.Wyckoff_symbol` and `_atom_site.site_symmetry_multiplicity` but excludes the derived `space_group_Wyckoff` loop. Ensure read ignores incoming @@ -351,14 +385,62 @@ The ADR commit + design-phase review/reply cleanup are handled by `_space_group_Wyckoff.{id,letter,multiplicity,site_symmetry,coords_xyz}` loop. Commit: `Serialize Wyckoff multiplicity and report Wyckoff table` -- [ ] **P1.9 — Promote ADR, close #51, remove stale TODOs.** `git mv` + + _P1.8 implementation decisions (implemented):_ + - **Project-CIF write of `_atom_site.site_symmetry_multiplicity`** + is already automatic: P1.4 added the `multiplicity` descriptor + with that CIF handler, and it is part of `AtomSite.parameters`, + so the atom-site loop emits it (value `?` for untabulated + sites). The `_space_group_Wyckoff` loop exclusion is already + provided by P1.3's `Structure._serializable_categories` + override. No new write-side code was needed in P1.8. + - **Read ignore of incoming `_space_group_Wyckoff.*`** is done by + a no-op `SpaceGroupWyckoffCollection.from_cif` override (the + structure read loop iterates *all* categories, including the + derived one). A hand-edited `_space_group_Wyckoff` loop is + discarded; the table is rebuilt from the space group on update. + - **Read ignore of incoming `_atom_site.site_symmetry_multiplicity`** + relies on re-derivation: the value is parsed into the + descriptor but overwritten by detection on the next + `_update_categories` (verified: file value `777` → re-derived + `1`). No extra read-side code. + - **Report `_space_group_Wyckoff.coords_xyz` = representative + coordinate only** (first orbit member, e.g. `(x,x,z)`), not the + full orbit. The collection stores the full centred orbit (up to + ~3551 chars for multiplicity-192 cubic positions), but the IUCr + report loop formatter rejects loop cells > 80 chars. Emitting + the representative keeps every space group's report valid and + matches the conventional ITA "Coordinates" entry. Decision + confirmed with the user during P1.8. The full orbit remains + available on the in-memory `space_group_wyckoff` category. + +- [x] **P1.9 — Promote ADR, close #51, remove stale TODOs.** `git mv` `wyckoff-letter-detection.md` from `suggestions/` to `accepted/`, set `**Status:** Accepted`, flip its `docs/dev/adrs/index.md` row to `Accepted`, and fix links with `git grep -n`. Move issue #51 from `open.md` to `closed.md` and delete the resolved TODOs in `default.py` (~200–211, ~225, ~569). Commit: `Promote wyckoff-letter-detection ADR and close issue #51` -- [ ] **P1.10 — Phase 1 review gate.** No code. Mark this `[x]`, commit + + _P1.9 notes (implemented):_ + - ADR moved with `git mv` to `accepted/`, `**Status:** Accepted`, + `index.md` row flipped to `Accepted` with the `accepted/` link. + - Inbound links to the old `suggestions/` path fixed in + `accepted/space-group-database.md` (5) and + `plans/space-group-database.md` (2), plus this plan's own ADR + cross-references. The ADR's `../../../../` root paths are + depth-invariant and its `../accepted/` sibling links still + resolve, so they were left unchanged (minimal diff). + - #51 moved from `open.md` (detailed section + summary-table row) + to `closed.md`. + - The `default.py` TODOs #51 referenced (old lines ~163/179/353, + about the hardcoded allowed-letter list and the missing-letter + case) were **already removed** when P1.5/P1.6 rewrote those + methods to resolve #51, so there is no `default.py` change in + this step. The only remaining TODO (label-regex/dict-key, line + ~68) is unrelated to #51 and was intentionally left. + +- [x] **P1.10 — Phase 1 review gate.** No code. Mark this `[x]`, commit the checklist update alone, then stop for the Phase 1 review. Commit: `Reach Phase 1 review gate` diff --git a/docs/docs/tutorials/ed-10.ipynb b/docs/docs/tutorials/ed-10.ipynb index e2b947d23..3815f24ea 100644 --- a/docs/docs/tutorials/ed-10.ipynb +++ b/docs/docs/tutorials/ed-10.ipynb @@ -112,7 +112,6 @@ " fract_x=0.0,\n", " fract_y=0.0,\n", " fract_z=0.0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-10.py b/docs/docs/tutorials/ed-10.py index 1b5077fb8..751831508 100644 --- a/docs/docs/tutorials/ed-10.py +++ b/docs/docs/tutorials/ed-10.py @@ -39,7 +39,6 @@ fract_x=0.0, fract_y=0.0, fract_z=0.0, - wyckoff_letter='a', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-11.ipynb b/docs/docs/tutorials/ed-11.ipynb index 13f867925..b5d03cf56 100644 --- a/docs/docs/tutorials/ed-11.ipynb +++ b/docs/docs/tutorials/ed-11.ipynb @@ -139,7 +139,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-11.py b/docs/docs/tutorials/ed-11.py index e8d878f27..e08e81d63 100644 --- a/docs/docs/tutorials/ed-11.py +++ b/docs/docs/tutorials/ed-11.py @@ -47,7 +47,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-12.ipynb b/docs/docs/tutorials/ed-12.ipynb index 4724b20b6..ed2c0a0b5 100644 --- a/docs/docs/tutorials/ed-12.ipynb +++ b/docs/docs/tutorials/ed-12.ipynb @@ -144,7 +144,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=1.0,\n", ")\n", "project.structures['nacl'].atom_sites.create(\n", @@ -153,7 +152,6 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=1.0,\n", ")" ] diff --git a/docs/docs/tutorials/ed-12.py b/docs/docs/tutorials/ed-12.py index 2e38828ea..3add79927 100644 --- a/docs/docs/tutorials/ed-12.py +++ b/docs/docs/tutorials/ed-12.py @@ -52,7 +52,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=1.0, ) project.structures['nacl'].atom_sites.create( @@ -61,7 +60,6 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, - wyckoff_letter='b', adp_iso=1.0, ) diff --git a/docs/docs/tutorials/ed-13.ipynb b/docs/docs/tutorials/ed-13.ipynb index 1ac483d2c..9a84c90cd 100644 --- a/docs/docs/tutorials/ed-13.ipynb +++ b/docs/docs/tutorials/ed-13.ipynb @@ -710,7 +710,7 @@ "_atom_site.occupancy\n", "_atom_site.ADP_type\n", "_atom_site.B_iso_or_equiv\n", - "Si Si 0 0 0 a 1.0 Biso 0.89\n", + "Si Si 0.125 0.125 0.125 a 1.0 Biso 0.89\n", "```" ] }, @@ -838,10 +838,9 @@ "project_1.structures['si'].atom_sites.create(\n", " label='Si',\n", " type_symbol='Si',\n", - " fract_x=0,\n", - " fract_y=0,\n", - " fract_z=0,\n", - " wyckoff_letter='a',\n", + " fract_x=0.125,\n", + " fract_y=0.125,\n", + " fract_z=0.125,\n", " adp_iso=0.89,\n", ")" ] @@ -1805,7 +1804,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.95,\n", " occupancy=0.5,\n", ")\n", @@ -1815,7 +1813,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.95,\n", " occupancy=0.5,\n", ")\n", @@ -1825,7 +1822,6 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=0.80,\n", ")\n", "project_2.structures['lbco'].atom_sites.create(\n", @@ -1834,7 +1830,6 @@ " fract_x=0,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='c',\n", " adp_iso=1.66,\n", ")" ] @@ -2517,10 +2512,9 @@ "project_2.structures['si'].atom_sites.create(\n", " label='Si',\n", " type_symbol='Si',\n", - " fract_x=0,\n", - " fract_y=0,\n", - " fract_z=0,\n", - " wyckoff_letter='a',\n", + " fract_x=0.125,\n", + " fract_y=0.125,\n", + " fract_z=0.125,\n", " adp_iso=0.89,\n", ")\n", "\n", diff --git a/docs/docs/tutorials/ed-13.py b/docs/docs/tutorials/ed-13.py index 454f82502..82970780f 100644 --- a/docs/docs/tutorials/ed-13.py +++ b/docs/docs/tutorials/ed-13.py @@ -438,7 +438,7 @@ # _atom_site.occupancy # _atom_site.ADP_type # _atom_site.B_iso_or_equiv -# Si Si 0 0 0 a 1.0 Biso 0.89 +# Si Si 0.125 0.125 0.125 a 1.0 Biso 0.89 # ``` # %% [markdown] @@ -493,10 +493,9 @@ project_1.structures['si'].atom_sites.create( label='Si', type_symbol='Si', - fract_x=0, - fract_y=0, - fract_z=0, - wyckoff_letter='a', + fract_x=0.125, + fract_y=0.125, + fract_z=0.125, adp_iso=0.89, ) @@ -1030,7 +1029,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.95, occupancy=0.5, ) @@ -1040,7 +1038,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.95, occupancy=0.5, ) @@ -1050,7 +1047,6 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, - wyckoff_letter='b', adp_iso=0.80, ) project_2.structures['lbco'].atom_sites.create( @@ -1059,7 +1055,6 @@ fract_x=0, fract_y=0.5, fract_z=0.5, - wyckoff_letter='c', adp_iso=1.66, ) @@ -1408,10 +1403,9 @@ project_2.structures['si'].atom_sites.create( label='Si', type_symbol='Si', - fract_x=0, - fract_y=0, - fract_z=0, - wyckoff_letter='a', + fract_x=0.125, + fract_y=0.125, + fract_z=0.125, adp_iso=0.89, ) diff --git a/docs/docs/tutorials/ed-16.ipynb b/docs/docs/tutorials/ed-16.ipynb index 92092f299..09c816f05 100644 --- a/docs/docs/tutorials/ed-16.ipynb +++ b/docs/docs/tutorials/ed-16.ipynb @@ -137,7 +137,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.2,\n", ")" ] diff --git a/docs/docs/tutorials/ed-16.py b/docs/docs/tutorials/ed-16.py index 2ede48cfb..89e60625f 100644 --- a/docs/docs/tutorials/ed-16.py +++ b/docs/docs/tutorials/ed-16.py @@ -52,7 +52,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.2, ) diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb index 0c69f1348..351290746 100644 --- a/docs/docs/tutorials/ed-17.ipynb +++ b/docs/docs/tutorials/ed-17.ipynb @@ -177,7 +177,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.3,\n", ")\n", "struct.atom_sites.create(\n", @@ -186,7 +185,6 @@ " fract_x=0.279,\n", " fract_y=0.25,\n", " fract_z=0.985,\n", - " wyckoff_letter='c',\n", " adp_iso=0.3,\n", ")\n", "struct.atom_sites.create(\n", @@ -195,7 +193,6 @@ " fract_x=0.094,\n", " fract_y=0.25,\n", " fract_z=0.429,\n", - " wyckoff_letter='c',\n", " adp_iso=0.34,\n", ")\n", "struct.atom_sites.create(\n", @@ -204,7 +201,6 @@ " fract_x=0.091,\n", " fract_y=0.25,\n", " fract_z=0.771,\n", - " wyckoff_letter='c',\n", " adp_iso=0.63,\n", ")\n", "struct.atom_sites.create(\n", @@ -213,7 +209,6 @@ " fract_x=0.448,\n", " fract_y=0.25,\n", " fract_z=0.217,\n", - " wyckoff_letter='c',\n", " adp_iso=0.59,\n", ")\n", "struct.atom_sites.create(\n", @@ -222,7 +217,6 @@ " fract_x=0.164,\n", " fract_y=0.032,\n", " fract_z=0.28,\n", - " wyckoff_letter='d',\n", " adp_iso=0.83,\n", ")" ] diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py index e1879bd20..eb3605e9d 100644 --- a/docs/docs/tutorials/ed-17.py +++ b/docs/docs/tutorials/ed-17.py @@ -68,7 +68,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.3, ) struct.atom_sites.create( @@ -77,7 +76,6 @@ fract_x=0.279, fract_y=0.25, fract_z=0.985, - wyckoff_letter='c', adp_iso=0.3, ) struct.atom_sites.create( @@ -86,7 +84,6 @@ fract_x=0.094, fract_y=0.25, fract_z=0.429, - wyckoff_letter='c', adp_iso=0.34, ) struct.atom_sites.create( @@ -95,7 +92,6 @@ fract_x=0.091, fract_y=0.25, fract_z=0.771, - wyckoff_letter='c', adp_iso=0.63, ) struct.atom_sites.create( @@ -104,7 +100,6 @@ fract_x=0.448, fract_y=0.25, fract_z=0.217, - wyckoff_letter='c', adp_iso=0.59, ) struct.atom_sites.create( @@ -113,7 +108,6 @@ fract_x=0.164, fract_y=0.032, fract_z=0.28, - wyckoff_letter='d', adp_iso=0.83, ) diff --git a/docs/docs/tutorials/ed-2.ipynb b/docs/docs/tutorials/ed-2.ipynb index b2ca221f2..9b1872b5b 100644 --- a/docs/docs/tutorials/ed-2.ipynb +++ b/docs/docs/tutorials/ed-2.ipynb @@ -145,7 +145,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", " occupancy=0.5,\n", ")\n", @@ -155,7 +154,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", " occupancy=0.5,\n", ")\n", @@ -165,7 +163,6 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -174,7 +171,6 @@ " fract_x=0,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-2.py b/docs/docs/tutorials/ed-2.py index 5ede4537b..008ca5d0f 100644 --- a/docs/docs/tutorials/ed-2.py +++ b/docs/docs/tutorials/ed-2.py @@ -56,7 +56,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, occupancy=0.5, ) @@ -66,7 +65,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, occupancy=0.5, ) @@ -76,7 +74,6 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, - wyckoff_letter='b', adp_iso=0.5, ) structure.atom_sites.create( @@ -85,7 +82,6 @@ fract_x=0, fract_y=0.5, fract_z=0.5, - wyckoff_letter='c', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-20.ipynb b/docs/docs/tutorials/ed-20.ipynb index 4c32724d0..6dc22803e 100644 --- a/docs/docs/tutorials/ed-20.ipynb +++ b/docs/docs/tutorials/ed-20.ipynb @@ -90,7 +90,6 @@ " fract_x=0.0,\n", " fract_y=0.0,\n", " fract_z=0.0,\n", - " wyckoff_letter='a',\n", " adp_type='Biso',\n", " adp_iso=1.0,\n", ")" @@ -124,7 +123,6 @@ " fract_x=0.0,\n", " fract_y=0.0,\n", " fract_z=0.0,\n", - " wyckoff_letter='a',\n", " adp_type='Biso',\n", " adp_iso=1.0,\n", ")" diff --git a/docs/docs/tutorials/ed-20.py b/docs/docs/tutorials/ed-20.py index b54a090b3..5b4b6fe03 100644 --- a/docs/docs/tutorials/ed-20.py +++ b/docs/docs/tutorials/ed-20.py @@ -41,7 +41,6 @@ fract_x=0.0, fract_y=0.0, fract_z=0.0, - wyckoff_letter='a', adp_type='Biso', adp_iso=1.0, ) @@ -63,7 +62,6 @@ fract_x=0.0, fract_y=0.0, fract_z=0.0, - wyckoff_letter='a', adp_type='Biso', adp_iso=1.0, ) diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb index 8076de33a..cce439297 100644 --- a/docs/docs/tutorials/ed-3.ipynb +++ b/docs/docs/tutorials/ed-3.ipynb @@ -267,7 +267,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", " occupancy=0.5,\n", ")\n", @@ -277,7 +276,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", " occupancy=0.5,\n", ")\n", @@ -287,7 +285,6 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=0.5,\n", ")\n", "project.structures['lbco'].atom_sites.create(\n", @@ -296,7 +293,6 @@ " fract_x=0,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py index dff71180b..6b1c06dc1 100644 --- a/docs/docs/tutorials/ed-3.py +++ b/docs/docs/tutorials/ed-3.py @@ -112,7 +112,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, occupancy=0.5, ) @@ -122,7 +121,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, occupancy=0.5, ) @@ -132,7 +130,6 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, - wyckoff_letter='b', adp_iso=0.5, ) project.structures['lbco'].atom_sites.create( @@ -141,7 +138,6 @@ fract_x=0, fract_y=0.5, fract_z=0.5, - wyckoff_letter='c', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-4.ipynb b/docs/docs/tutorials/ed-4.ipynb index 2bc849990..c45ed29cf 100644 --- a/docs/docs/tutorials/ed-4.ipynb +++ b/docs/docs/tutorials/ed-4.ipynb @@ -142,7 +142,6 @@ " fract_x=0.1876,\n", " fract_y=0.25,\n", " fract_z=0.167,\n", - " wyckoff_letter='c',\n", " adp_iso=1.37,\n", ")\n", "structure.atom_sites.create(\n", @@ -151,7 +150,6 @@ " fract_x=0.0654,\n", " fract_y=0.25,\n", " fract_z=0.684,\n", - " wyckoff_letter='c',\n", " adp_iso=0.3777,\n", ")\n", "structure.atom_sites.create(\n", @@ -160,7 +158,6 @@ " fract_x=0.9082,\n", " fract_y=0.25,\n", " fract_z=0.5954,\n", - " wyckoff_letter='c',\n", " adp_iso=1.9764,\n", ")\n", "structure.atom_sites.create(\n", @@ -169,7 +166,6 @@ " fract_x=0.1935,\n", " fract_y=0.25,\n", " fract_z=0.5432,\n", - " wyckoff_letter='c',\n", " adp_iso=1.4456,\n", ")\n", "structure.atom_sites.create(\n", @@ -178,7 +174,6 @@ " fract_x=0.0811,\n", " fract_y=0.0272,\n", " fract_z=0.8086,\n", - " wyckoff_letter='d',\n", " adp_iso=1.2822,\n", ")" ] diff --git a/docs/docs/tutorials/ed-4.py b/docs/docs/tutorials/ed-4.py index 39ea4b150..0e974893a 100644 --- a/docs/docs/tutorials/ed-4.py +++ b/docs/docs/tutorials/ed-4.py @@ -55,7 +55,6 @@ fract_x=0.1876, fract_y=0.25, fract_z=0.167, - wyckoff_letter='c', adp_iso=1.37, ) structure.atom_sites.create( @@ -64,7 +63,6 @@ fract_x=0.0654, fract_y=0.25, fract_z=0.684, - wyckoff_letter='c', adp_iso=0.3777, ) structure.atom_sites.create( @@ -73,7 +71,6 @@ fract_x=0.9082, fract_y=0.25, fract_z=0.5954, - wyckoff_letter='c', adp_iso=1.9764, ) structure.atom_sites.create( @@ -82,7 +79,6 @@ fract_x=0.1935, fract_y=0.25, fract_z=0.5432, - wyckoff_letter='c', adp_iso=1.4456, ) structure.atom_sites.create( @@ -91,7 +87,6 @@ fract_x=0.0811, fract_y=0.0272, fract_z=0.8086, - wyckoff_letter='d', adp_iso=1.2822, ) diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb index 6aa0fedb2..391ca3651 100644 --- a/docs/docs/tutorials/ed-5.ipynb +++ b/docs/docs/tutorials/ed-5.ipynb @@ -138,7 +138,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -147,7 +146,6 @@ " fract_x=0.279,\n", " fract_y=0.25,\n", " fract_z=0.985,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -156,7 +154,6 @@ " fract_x=0.094,\n", " fract_y=0.25,\n", " fract_z=0.429,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -165,7 +162,6 @@ " fract_x=0.091,\n", " fract_y=0.25,\n", " fract_z=0.771,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -174,7 +170,6 @@ " fract_x=0.448,\n", " fract_y=0.25,\n", " fract_z=0.217,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -183,7 +178,6 @@ " fract_x=0.164,\n", " fract_y=0.032,\n", " fract_z=0.28,\n", - " wyckoff_letter='d',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py index 1e1c27dda..942444d25 100644 --- a/docs/docs/tutorials/ed-5.py +++ b/docs/docs/tutorials/ed-5.py @@ -53,7 +53,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.5, ) structure.atom_sites.create( @@ -62,7 +61,6 @@ fract_x=0.279, fract_y=0.25, fract_z=0.985, - wyckoff_letter='c', adp_iso=0.5, ) structure.atom_sites.create( @@ -71,7 +69,6 @@ fract_x=0.094, fract_y=0.25, fract_z=0.429, - wyckoff_letter='c', adp_iso=0.5, ) structure.atom_sites.create( @@ -80,7 +77,6 @@ fract_x=0.091, fract_y=0.25, fract_z=0.771, - wyckoff_letter='c', adp_iso=0.5, ) structure.atom_sites.create( @@ -89,7 +85,6 @@ fract_x=0.448, fract_y=0.25, fract_z=0.217, - wyckoff_letter='c', adp_iso=0.5, ) structure.atom_sites.create( @@ -98,7 +93,6 @@ fract_x=0.164, fract_y=0.032, fract_z=0.28, - wyckoff_letter='d', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-6.ipynb b/docs/docs/tutorials/ed-6.ipynb index 488690968..561afb750 100644 --- a/docs/docs/tutorials/ed-6.ipynb +++ b/docs/docs/tutorials/ed-6.ipynb @@ -136,7 +136,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -145,7 +144,6 @@ " fract_x=0.5,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='e',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -154,7 +152,6 @@ " fract_x=0.21,\n", " fract_y=-0.21,\n", " fract_z=0.06,\n", - " wyckoff_letter='h',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -163,7 +160,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0.197,\n", - " wyckoff_letter='c',\n", " adp_iso=0.5,\n", ")\n", "structure.atom_sites.create(\n", @@ -172,7 +168,6 @@ " fract_x=0.13,\n", " fract_y=-0.13,\n", " fract_z=0.08,\n", - " wyckoff_letter='h',\n", " adp_iso=0.5,\n", ")" ] diff --git a/docs/docs/tutorials/ed-6.py b/docs/docs/tutorials/ed-6.py index b57c56952..f2968b774 100644 --- a/docs/docs/tutorials/ed-6.py +++ b/docs/docs/tutorials/ed-6.py @@ -50,7 +50,6 @@ fract_x=0, fract_y=0, fract_z=0.5, - wyckoff_letter='b', adp_iso=0.5, ) structure.atom_sites.create( @@ -59,7 +58,6 @@ fract_x=0.5, fract_y=0, fract_z=0, - wyckoff_letter='e', adp_iso=0.5, ) structure.atom_sites.create( @@ -68,7 +66,6 @@ fract_x=0.21, fract_y=-0.21, fract_z=0.06, - wyckoff_letter='h', adp_iso=0.5, ) structure.atom_sites.create( @@ -77,7 +74,6 @@ fract_x=0, fract_y=0, fract_z=0.197, - wyckoff_letter='c', adp_iso=0.5, ) structure.atom_sites.create( @@ -86,7 +82,6 @@ fract_x=0.13, fract_y=-0.13, fract_z=0.08, - wyckoff_letter='h', adp_iso=0.5, ) diff --git a/docs/docs/tutorials/ed-8.ipynb b/docs/docs/tutorials/ed-8.ipynb index e564ca387..7e02e5770 100644 --- a/docs/docs/tutorials/ed-8.ipynb +++ b/docs/docs/tutorials/ed-8.ipynb @@ -136,7 +136,6 @@ " fract_x=0.4663,\n", " fract_y=0.0,\n", " fract_z=0.25,\n", - " wyckoff_letter='b',\n", " adp_iso=0.92,\n", ")\n", "structure.atom_sites.create(\n", @@ -145,7 +144,6 @@ " fract_x=0.2521,\n", " fract_y=0.2521,\n", " fract_z=0.2521,\n", - " wyckoff_letter='a',\n", " adp_iso=0.73,\n", ")\n", "structure.atom_sites.create(\n", @@ -154,7 +152,6 @@ " fract_x=0.0851,\n", " fract_y=0.0851,\n", " fract_z=0.0851,\n", - " wyckoff_letter='a',\n", " adp_iso=2.08,\n", ")\n", "structure.atom_sites.create(\n", @@ -163,7 +160,6 @@ " fract_x=0.1377,\n", " fract_y=0.3054,\n", " fract_z=0.1195,\n", - " wyckoff_letter='c',\n", " adp_iso=0.90,\n", ")\n", "structure.atom_sites.create(\n", @@ -172,7 +168,6 @@ " fract_x=0.3625,\n", " fract_y=0.3633,\n", " fract_z=0.1867,\n", - " wyckoff_letter='c',\n", " adp_iso=1.37,\n", ")\n", "structure.atom_sites.create(\n", @@ -181,7 +176,6 @@ " fract_x=0.4612,\n", " fract_y=0.4612,\n", " fract_z=0.4612,\n", - " wyckoff_letter='a',\n", " adp_iso=0.88,\n", ")" ] diff --git a/docs/docs/tutorials/ed-8.py b/docs/docs/tutorials/ed-8.py index 09917e35c..d875c3998 100644 --- a/docs/docs/tutorials/ed-8.py +++ b/docs/docs/tutorials/ed-8.py @@ -51,7 +51,6 @@ fract_x=0.4663, fract_y=0.0, fract_z=0.25, - wyckoff_letter='b', adp_iso=0.92, ) structure.atom_sites.create( @@ -60,7 +59,6 @@ fract_x=0.2521, fract_y=0.2521, fract_z=0.2521, - wyckoff_letter='a', adp_iso=0.73, ) structure.atom_sites.create( @@ -69,7 +67,6 @@ fract_x=0.0851, fract_y=0.0851, fract_z=0.0851, - wyckoff_letter='a', adp_iso=2.08, ) structure.atom_sites.create( @@ -78,7 +75,6 @@ fract_x=0.1377, fract_y=0.3054, fract_z=0.1195, - wyckoff_letter='c', adp_iso=0.90, ) structure.atom_sites.create( @@ -87,7 +83,6 @@ fract_x=0.3625, fract_y=0.3633, fract_z=0.1867, - wyckoff_letter='c', adp_iso=1.37, ) structure.atom_sites.create( @@ -96,7 +91,6 @@ fract_x=0.4612, fract_y=0.4612, fract_z=0.4612, - wyckoff_letter='a', adp_iso=0.88, ) diff --git a/docs/docs/tutorials/ed-9.ipynb b/docs/docs/tutorials/ed-9.ipynb index 8722b614e..8d1ecdfee 100644 --- a/docs/docs/tutorials/ed-9.ipynb +++ b/docs/docs/tutorials/ed-9.ipynb @@ -133,7 +133,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.2,\n", " occupancy=0.5,\n", ")\n", @@ -143,7 +142,6 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.2,\n", " occupancy=0.5,\n", ")\n", @@ -153,7 +151,6 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='b',\n", " adp_iso=0.2567,\n", ")\n", "structure_1.atom_sites.create(\n", @@ -162,7 +159,6 @@ " fract_x=0,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", - " wyckoff_letter='c',\n", " adp_iso=1.4041,\n", ")" ] @@ -243,7 +239,6 @@ " fract_x=0.0,\n", " fract_y=0.0,\n", " fract_z=0.0,\n", - " wyckoff_letter='a',\n", " adp_iso=0.0,\n", ")" ] diff --git a/docs/docs/tutorials/ed-9.py b/docs/docs/tutorials/ed-9.py index 083bde68f..4fe451af2 100644 --- a/docs/docs/tutorials/ed-9.py +++ b/docs/docs/tutorials/ed-9.py @@ -48,7 +48,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.2, occupancy=0.5, ) @@ -58,7 +57,6 @@ fract_x=0, fract_y=0, fract_z=0, - wyckoff_letter='a', adp_iso=0.2, occupancy=0.5, ) @@ -68,7 +66,6 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, - wyckoff_letter='b', adp_iso=0.2567, ) structure_1.atom_sites.create( @@ -77,7 +74,6 @@ fract_x=0, fract_y=0.5, fract_z=0.5, - wyckoff_letter='c', adp_iso=1.4041, ) @@ -110,7 +106,6 @@ fract_x=0.0, fract_y=0.0, fract_z=0.0, - wyckoff_letter='a', adp_iso=0.0, ) diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index c39db75d5..16fcd818b 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -489,36 +489,25 @@ def _update_atom_multiplicity( structure: Structure, ) -> None: """ - Update cryspy atom multiplicities. + Update cryspy atom multiplicities from the model. CrysPy normalizes fractional coordinates into the ``[0, 1)`` interval while parsing CIF. For sites such as ``(x, -x, z)``, that can turn ``-x`` into ``1 - x`` before the Wyckoff multiplicity is inferred, making special positions look like - general positions. EasyDiffraction already stores the intended - Wyckoff letter, so keep the calculator dictionary aligned with - that model state. + general positions. EasyDiffraction's Wyckoff detection already + stores the correct per-site multiplicity, so use it; when a site + has no detected multiplicity (untabulated space group), keep the + backend's inferred value. """ if cryspy is None: return - from cryspy.A_functions_base.function_2_space_group import ( # noqa: PLC0415 - get_it_number_by_name_hm_short, - ) - - from easydiffraction.crystallography.space_groups import SPACE_GROUPS # noqa: PLC0415 - - it_number = get_it_number_by_name_hm_short(structure.space_group.name_h_m.value) - coord_code = structure.space_group.it_coordinate_system_code.value - if it_number is None or (it_number, coord_code) not in SPACE_GROUPS: - return - - positions = SPACE_GROUPS[it_number, coord_code]['Wyckoff_positions'] multiplicity = cryspy_model_dict['atom_multiplicity'] for idx, atom_site in enumerate(structure.atom_sites): - wyckoff_letter = atom_site.wyckoff_letter.value - if wyckoff_letter in positions: - multiplicity[idx] = positions[wyckoff_letter]['multiplicity'] + site_multiplicity = atom_site.multiplicity.value + if site_multiplicity is not None: + multiplicity[idx] = site_multiplicity @staticmethod def _update_aniso_beta( diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index 7dd8c731d..5b55f34df 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -377,9 +377,12 @@ def _residual_function( # Update categories to reflect new parameter values # Order matters: structures first (symmetry, structure), - # then analysis (constraints), then experiments (calculations) + # then analysis (constraints), then experiments (calculations). + # Pass called_by_minimizer so the per-iteration Wyckoff snap + # runs silently (no re-detection or warnings); detection + # already ran at fit setup. for structure in structures: - structure._update_categories() + structure._update_categories(called_by_minimizer=True) if analysis is not None: analysis._update_categories(called_by_minimizer=True) diff --git a/src/easydiffraction/core/validation.py b/src/easydiffraction/core/validation.py index 546719fe9..680edd82e 100644 --- a/src/easydiffraction/core/validation.py +++ b/src/easydiffraction/core/validation.py @@ -252,6 +252,36 @@ def validated( # ====================================================================== +class PermissiveMembershipValidator(MembershipValidator): + """ + Membership validator accepting any value when choices are empty. + + Used where the allowed set is derived dynamically and may + legitimately be empty (for example a Wyckoff letter under an + untabulated space group, or before a parent context is available): + an empty allowed set stores the value verbatim instead of rejecting + it. A non-empty allowed set validates membership as usual. + """ + + def validated( + self, + value: object, + name: str, + default: object = None, + current: object = None, + ) -> object: + """ + Accept any value when allowed is empty, else check membership. + """ + allowed_values = self.allowed() if callable(self.allowed) else self.allowed + if not allowed_values: + return value + return super().validated(value, name, default=default, current=current) + + +# ====================================================================== + + class RegexValidator(ValidatorBase): """Ensure that a string matches a given regular expression.""" diff --git a/src/easydiffraction/crystallography/__init__.py b/src/easydiffraction/crystallography/__init__.py index 4e798e209..080e2094d 100644 --- a/src/easydiffraction/crystallography/__init__.py +++ b/src/easydiffraction/crystallography/__init__.py @@ -1,2 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause + +from easydiffraction.crystallography.crystallography import WyckoffPosition +from easydiffraction.crystallography.crystallography import detect_wyckoff_position +from easydiffraction.crystallography.crystallography import wyckoff_position_info diff --git a/src/easydiffraction/crystallography/crystallography.py b/src/easydiffraction/crystallography/crystallography.py index 8359d551a..e75569a4b 100644 --- a/src/easydiffraction/crystallography/crystallography.py +++ b/src/easydiffraction/crystallography/crystallography.py @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import itertools +import operator +from dataclasses import dataclass from fractions import Fraction from typing import Any @@ -16,6 +19,59 @@ from easydiffraction.crystallography.space_groups import SPACE_GROUPS from easydiffraction.utils.logging import log +# Maximum residual (in fractional units) for a coordinate to be +# considered on a Wyckoff orbit during detection. +_WYCKOFF_DETECTION_TOL = 1e-3 + + +@dataclass(frozen=True) +class WyckoffPosition: + """ + A resolved Wyckoff position and the orbit representative matched. + + Attributes + ---------- + letter : str + Wyckoff letter (e.g. ``'h'``). + multiplicity : int + Site multiplicity (the full orbit size). + site_symmetry : str + International Tables site-symmetry symbol. + coord_template : str | None + Nearest matched orbit representative (e.g. ``'(x,-x,z)'``), or + ``None`` for a table lookup made without coordinates. This is + what coordinate snapping and constrained-axis flags consume. + """ + + letter: str + multiplicity: int + site_symmetry: str + coord_template: str | None + + +def _normalize_coord_code(coord_code: str | None) -> str | None: + """ + Normalize a coordinate-system code to the ``SPACE_GROUPS`` key form. + + The empty string (used by consumers for groups with no coordinate + code, e.g. triclinic P1/P-1) maps to ``None``, which is how those + settings are keyed in ``SPACE_GROUPS``. + + Parameters + ---------- + coord_code : str | None + Incoming coordinate-system code. + + Returns + ------- + str | None + ``None`` for the empty string, otherwise ``coord_code`` + unchanged. + """ + if not coord_code: + return None + return coord_code + def apply_cell_symmetry_constraints( cell: dict[str, float], @@ -202,14 +258,13 @@ def _get_wyckoff_exprs( log.error(f"Failed to get IT_number for name_H-M '{name_hm}'") return None - if coord_code is None: - log.error('IT_coordinate_system_code is not set') - return None - + coord_code = _normalize_coord_code(coord_code) if (it_number, coord_code) not in SPACE_GROUPS: - # Space group is not in the local SPACE_GROUPS table (e.g. P 1, - # where cryspy reports no coordinate-system codes). Treat as - # "no symmetry constraints to apply". + # Space group / coordinate-system combination is absent + # from the local SPACE_GROUPS table. Treat as "no symmetry + # constraints to apply". Triclinic groups are keyed + # ``(it_number, None)`` and resolve normally through this + # lookup, so a ``None`` code is not treated as unset. return None entry = SPACE_GROUPS[it_number, coord_code] @@ -415,7 +470,7 @@ def _get_general_position_ops( list[tuple[np.ndarray, np.ndarray]] | None List of (rotation, translation) pairs, or ``None`` on failure. """ - key = (it_number, coord_code) + key = (it_number, _normalize_coord_code(coord_code)) if key not in SPACE_GROUPS: # Not in the local SPACE_GROUPS table (e.g. P 1, where cryspy # reports no coordinate-system codes). The caller falls back to @@ -430,6 +485,287 @@ def _get_general_position_ops( return [_parse_rotation_matrix(c) for c in general_coords] +def _orbit_template_residual(point: np.ndarray, rot: np.ndarray, trans: np.ndarray) -> float: + """ + Return the mod-1 distance from ``point`` to an orbit template. + + The template manifold is ``{rot·v + trans}``; the point lies on it + for some free ``v`` when the residual is ~0. Integer lattice shifts + account for unit-cell periodicity. + + Parameters + ---------- + point : np.ndarray + Fractional coordinate reduced into the unit cell. + rot : np.ndarray + (3, 3) rotation part of the template. + trans : np.ndarray + (3,) translation part of the template. + + Returns + ------- + float + Smallest residual over the candidate lattice shifts. + """ + rot_float = rot.astype(float) + base = point - trans + best = np.inf + for shift in itertools.product((-1.0, 0.0, 1.0), repeat=3): + rhs = base - np.array(shift) + solution, *_ = np.linalg.lstsq(rot_float, rhs, rcond=None) + residual = float(np.linalg.norm(rot_float @ solution - rhs)) + best = min(best, residual) + return best + + +def _nearest_orbit_template(point: np.ndarray, coords_xyz: list[str]) -> tuple[str, float]: + """ + Return the orbit template nearest to ``point`` and its residual. + + On near-ties (e.g. centering copies that share a manifold mod 1) the + earlier, canonical representative is kept for deterministic output; + the free-parameter-solving snap downstream is correct for any of + them. + """ + best_template = coords_xyz[0] + best_residual = np.inf + for template in coords_xyz: + rot, trans = _parse_rotation_matrix(template) + residual = _orbit_template_residual(point, rot, trans) + if residual < best_residual - 1e-9: + best_residual = residual + best_template = template + return best_template, best_residual + + +def _wyckoff_template_constrained_flags(rot: np.ndarray) -> dict[str, bool]: + """ + Return per-axis symmetry-constraint flags for a parsed template. + + An axis is **free** when its coordinate is the first (in x, y, z + order) to introduce a free parameter, and **constrained** otherwise + (a constant, or a coordinate slaved to an earlier axis's parameter). + This is slot-based, so it is correct for off-canonical + representatives such as ``(0,x,0)`` (``fract_x`` constrained, + ``fract_y`` free) where the symbol-presence test would be wrong. + + Parameters + ---------- + rot : np.ndarray + (3, 3) rotation part from :func:`_parse_rotation_matrix`. + + Returns + ------- + dict[str, bool] + Mapping ``'fract_x'/'fract_y'/'fract_z'`` to ``True`` if the + axis is fully fixed by site symmetry. + """ + claimed: set[int] = set() + flags: dict[str, bool] = {} + for axis, name in enumerate(('fract_x', 'fract_y', 'fract_z')): + used = {col for col in range(3) if rot[axis, col] != 0} + if used - claimed: + flags[name] = False + claimed |= used + else: + flags[name] = True + return flags + + +def snap_to_wyckoff_template( + coord_template: str, + fract_xyz: tuple[float, float, float], +) -> tuple[tuple[float, float, float], dict[str, bool]]: + """ + Project a coordinate onto a Wyckoff orbit-representative manifold. + + Solves the free Wyckoff parameters from the **free (refinable) + axes** only — keeping those axes' values, so a minimizer's refined + free coordinate is preserved — then derives the constrained axes + from the template. Replaces per-axis symbol substitution: it handles + coupled axes (e.g. ``(x,-x,z)``: keep ``fract_x``, set + ``fract_y=-fract_x``) and off-canonical representatives (e.g. + ``(0,x,0)``: keep ``fract_y``, set ``fract_x=fract_z=0``). + + Parameters + ---------- + coord_template : str + Selected orbit representative, e.g. ``'(x,-x,z)'`` or + ``'(0,x,0)'``. + fract_xyz : tuple[float, float, float] + Current fractional coordinate. + + Returns + ------- + tuple[tuple[float, float, float], dict[str, bool]] + The snapped ``(x, y, z)`` (free axes kept, constrained axes + derived; not reduced mod 1), and the per-axis constraint flags. + """ + rot, trans = _parse_rotation_matrix(coord_template) + rot_float = rot.astype(float) + point = np.asarray(fract_xyz, dtype=float) + axes = ('fract_x', 'fract_y', 'fract_z') + flags = _wyckoff_template_constrained_flags(rot) + free_rows = [axis for axis, name in enumerate(axes) if not flags[name]] + if free_rows: + solution, *_ = np.linalg.lstsq( + rot_float[free_rows, :], + point[free_rows] - trans[free_rows], + rcond=None, + ) + else: + solution = np.zeros(3) + derived = rot_float @ solution + trans + snapped = tuple( + float(point[axis]) if not flags[name] else float(derived[axis]) + for axis, name in enumerate(axes) + ) + return snapped, flags + + +def detect_wyckoff_position( + name_hm: str, + coord_code: str | None, + fract_xyz: tuple[float, float, float], + tol: float = _WYCKOFF_DETECTION_TOL, +) -> WyckoffPosition | None: + """ + Detect the Wyckoff position a fractional coordinate occupies. + + Tests the coordinate for membership in every Wyckoff orbit of the + resolved space group and returns the matched position with its + nearest representative template. The winner is chosen by + multiplicity ascending, then residual ascending (the most special + site first); a rare same-multiplicity tie within ``tol`` is reported + with a warning. + + Parameters + ---------- + name_hm : str + Hermann-Mauguin symbol of the space group. + coord_code : str | None + Coordinate-system code. + fract_xyz : tuple[float, float, float] + Fractional coordinate to classify. + tol : float, default=_WYCKOFF_DETECTION_TOL + Maximum residual for orbit membership. + + Returns + ------- + WyckoffPosition | None + The matched position, or ``None`` when the space group is absent + from ``SPACE_GROUPS``. + """ + it_number = get_it_number_by_name_hm_short(name_hm) + if it_number is None: + return None + key = (it_number, _normalize_coord_code(coord_code)) + if key not in SPACE_GROUPS: + return None + + point = np.asarray(fract_xyz, dtype=float) % 1.0 + matches = [] + for letter, position in SPACE_GROUPS[key]['Wyckoff_positions'].items(): + template, residual = _nearest_orbit_template(point, position['coords_xyz']) + if residual <= tol: + matches.append( + ( + int(position['multiplicity']), + residual, + letter, + position['site_symmetry'], + template, + ), + ) + + if not matches: + return None + matches.sort(key=operator.itemgetter(0, 1)) + multiplicity, _residual, letter, site_symmetry, template = matches[0] + ties = [match for match in matches[1:] if match[0] == multiplicity] + if ties: + others = ', '.join(repr(match[2]) for match in ties) + log.warning( + f'Wyckoff detection tie for {name_hm!r}: chose {letter!r} ' + f'over {others} (same multiplicity)', + ) + return WyckoffPosition(letter, multiplicity, site_symmetry, template) + + +def wyckoff_position_info( + name_hm: str, + coord_code: str | None, + letter: str, + fract_xyz: tuple[float, float, float] | None = None, +) -> WyckoffPosition | None: + """ + Look up a Wyckoff letter and optionally its representative. + + Parameters + ---------- + name_hm : str + Hermann-Mauguin symbol of the space group. + coord_code : str | None + Coordinate-system code. + letter : str + Wyckoff letter to look up. + fract_xyz : tuple[float, float, float] | None, default=None + When given, the nearest orbit representative for ``letter`` is + selected as ``coord_template``; otherwise ``coord_template`` is + ``None``. + + Returns + ------- + WyckoffPosition | None + The position record, or ``None`` when the group or letter is + absent. + """ + it_number = get_it_number_by_name_hm_short(name_hm) + if it_number is None: + return None + key = (it_number, _normalize_coord_code(coord_code)) + if key not in SPACE_GROUPS: + return None + positions = SPACE_GROUPS[key]['Wyckoff_positions'] + if letter not in positions: + return None + position = positions[letter] + template = None + if fract_xyz is not None: + point = np.asarray(fract_xyz, dtype=float) % 1.0 + template, _residual = _nearest_orbit_template(point, position['coords_xyz']) + return WyckoffPosition( + letter, int(position['multiplicity']), position['site_symmetry'], template + ) + + +def space_group_wyckoff_table(name_hm: str, coord_code: str | None) -> dict[str, dict] | None: + """ + Return the Wyckoff-position table for a space group, or ``None``. + + Parameters + ---------- + name_hm : str + Hermann-Mauguin symbol of the space group. + coord_code : str | None + Coordinate-system code. + + Returns + ------- + dict[str, dict] | None + Mapping of Wyckoff letter to its ``multiplicity``, + ``site_symmetry``, and ``coords_xyz`` record, or ``None`` when + the space group is absent from ``SPACE_GROUPS``. + """ + it_number = get_it_number_by_name_hm_short(name_hm) + if it_number is None: + return None + key = (it_number, _normalize_coord_code(coord_code)) + if key not in SPACE_GROUPS: + return None + return SPACE_GROUPS[key]['Wyckoff_positions'] + + def _site_stabilizer_rotations( ops: list[tuple[np.ndarray, np.ndarray]], site_coords: tuple[float, float, float], diff --git a/src/easydiffraction/crystallography/space_groups.json.gz b/src/easydiffraction/crystallography/space_groups.json.gz index 75d4f80ce725ad001c05e8a07fa6a6604ab31a1c..198668e13220fc50fabbdeaf608007839b896913 100644 GIT binary patch delta 105983 zcmV)GK)%1)lLylE27e!m2mk;800092?7hp5BT2F)xbLqB)T#%hA=S(nkDS$n2auRp z43LWfW^vIdkaUknM$jW~R@QCO8~yL&biPRyQF*DVSdhq@b*pGjGgYIRs>qQe=lsWi zd-dvnJ^%aa)jvM|zyEyv_hR<&*FXNxSM7N7*}oqC{`>3W=YQu@Q zOVjV$@2(I2{nyv|_pgs%=es}oGX1_jzL@>^e)i?(4}bjm{MmoJ{LkkPmgCj<=^rou z>wj$j=JUhj!+&4XzkmGoujfDfU%Nm2^RKUquYZ62uk}FxWBaG;f&O^>=joxBBmaAQ zu0MZ#ef;~Mpa1&t@AXyxu^-3x>AxTTIsZQW*ZLa&XbzKmczmAC%N_g2@4FBGzt#NP zk58Ze`R9*+JzwU>ls|MtKC+wMPKrW<(G(A zLmgukS|#7Rd8vV9TZTIF+rP@W3UP*EwFtG8MSp0HE<(@CkZuv;1rg z*;(l|X#MX~?KLP6hA}I1KSE*{Mb@Ao%-k>6ppLQzO_4RITdzX8HE2m)g!IX~BzNbe z?myEaM9JI>EJ90CvOF&_kaQk2D|J6Y?L}xQ&c-HLvN824w3>19vC;ZRQ0HVPXJO=J z=YQWTy(CR5=4RWu8$Eh9F}U*4n=_;E^)1jx;)k>FqVbdOUD1=bo4e7Ir;of^gV1YA zj=eVToi_eDKMOSmi+t}|9=)Tviypn#*8A-THTwBMfPS7miJN`kcJAYGXaeN7Ujws2;w9FU-Ji=hKGLXWdSqH5Q z@6{{Ad)3OYd*EI|lvuoO5IH?PFU3w)hWCS98IFG>B)}i(a_!Ev_A#`TA>iW^tPG@Z zb=E^G!$f{h3$G~ zxGcOTEemLBp<5O{>X!v1voMMpR)5XYb5`*r>pZy1!~$6&l8MCtB8V`6g~B?5c%v(t z-KptHcLKUnDug&H@MtrOml&d1I^j3@4kGsaCPol(_EXhf8TMSD*eIRglUt%tY=prO za(GKx89KP8sa+X97_1EEHO-T~NLAB(&Mf@!9zP6tU5HmS&wi-->p~cVtACgOb-}Wt z`Hr+MAjySxUHE9WF2th-yUONrUAkC~R!&xlkAq(&un59Y;R1~!!T{C^X%z7$2NQXr z&8NG2P1nhi#1^ z`w@A6{kdMal`fDrdeXCY9e;C%5sY3qHSc_(rkywRh`5nSIh_$}1XSMZd*d2V5vt@H zw*}v7zi~mVj`cQ2P&wBJm2+NHsmv#ZEk?`^qs7nE=AmmadTY()UO#q^`p0fMXLlH> zpS{S5xP3-f`V1y15`yPY$sArkhf{;+kpCE(Jc7cFhBM`3 zIAg$S22Y^y>AT`5&-$r*qlK1c7SIx3$Hhd(MX2I(eUIH?>PC;9lKS)eI?XkB?B@LE zPG7XYI1&5#yUIkjIe+UQGIzqL8r z>-K(#uNz7oq<1A%d)<^BKkstrjx3b?U8~&<;qO{B2d;$V-G3bK-p8SDbxG>3<9BeR z@4S1LN77@V-1e9}z4WeU#GaSPf@S0NdEfWgqF@Y7up;kVL_}w)l+byXM=N8Y>U34S zZ&;(NO`ofv%{9~eb~nWLE#2h0a{a!IP=6%B`(`3Su~5dD%J&TpGfma))_r15IP4#X z`|hRUO#ge{NPjOK_s(A7VWC!d;M3EIB2g9^Q38pmJSm>$_#&?hYK}{2GFDusXa3{QkiMqZd!uLmjRos$s*GtQP5iWw#)^KnJq??J zmb3AH2tf^>ibg8U3m){h7Id#iW!YP-8$cj_nSW7G&X#srP?NFe`)&x&{l4#;Lkr9C z+`qG+UCop+jYOz`#l5m&UhN+FClMr{@sal)(Z+Y#V7%x+n!X@YgyBe7Od$hL+U3Tu%G?8l|QIjsA^dd4jcgckyj2u`bm65quDRNXKFx*)qxu{V{#)9E zj|4U|i@?Srcf|u6BmU&JJxifGdzFC=`n;zD8^trf$Kp+Wsh)ob)kEL>{)#TH)_fhe^RI3_30b5+r91Ag2{oX#K1NW8_-7D7Co6nLwtbAeBqB0;bipw1-Ih}Tc&$FT84RoYuoU0r02V#2y~QfgpSSP=@6&iO#8i{yY(E-pj|=wEWFvED8kX1qK=7L!^ZV zvtoBKK=&}@lKy7h!4SWHW}Adf1+Z0@0O!ma;5nBwUI%Q{PVP_*Pk+MlB%SN3_J{^1 zG$VDvkq*E%tPM`$1Mk-d9PAO%gx}L1fh@tn#U2sj_mH-URoo+j*nTXsN9b|$)&@A| z3n{12^JWWrS|jL3*H@$Hk#k+oCUG=-I+{M(L)mpViFFzgnbS)avGR~e1PHeFl}Mob zSa7gOfXP_!yGbCRSATkw=yJFRsXJ#rWa*q?bqqI`2O3B$t}$@qi)9rrO~=lgb7)P- zc-`rpx8@vazcRRoHMn>k2{8LA-??qtZekf?vn<3r*g(sptInp%J+7Jr~=g@JBu4LtqS(dYZj(I+Hha4I`_ddBFHrInWsglV%~7fMH; z@2?>G#KjZN#4aOOLXM}seB4H#W9**b@}DK6&t$9fHAJ5Qo>QRCs7&N#%f|SBO*>dY6SsClHp{#}D;Tcsn@+drn}|9)F%(3s0_vC+8wyO~9qWJy;r? zL#2Mh)kMHA30Qjz4_T$|&-)y@KLoV2NgTLDORa2u;xXmZc;#AH$URmn5fv3a|+ZMmBdOFfan+oUtM6O zLP&HB!GEqEuu?^6cf(344RPUGu+oWztGhDvyvrMUqJDV|av%>cqqlAHmj=KB%He0dj~5i}PR| zM53rTcx+YynNx5?6;&akV;FsPKou2ZqGJemb=%Vva!Wb=Two~}BYP)_nu4WoNzrEq z>p2-jpC5`upXYi`plVZ-I+91vX*FJYcYovga5b@KT+Jz=*3{6BJ5h6D$DSk5=<#UA zwPVkOyUg{(o! z_WW2d_C%WEdHwXw-$X7n$Ov_WcW4h7C4bf7 ziC&{rUF==CR-dR@_k>P$Cy{EKpcJr$1iYiA!97+Q1`w_O#0~X*7O%x$=6b8eIa2~E zZKJj36y~wfI_+!H3o})=(fVA`Y7x`%1O=LuY`4x%=Mw1nB{*q4Z-Q1`1!7BTRZDs zC2nnJ9kaE!w@G~W*jj)&*xq_*h%If16}PzHdxI-bSkP~A&Ydikw3gmls&})+O%1j< z!45~;-SX{hmb=<@?`Ztp%*#&J0XtbnOJ?2>12Dkgz-%*A@jLpNj5d{arGLXT+w86N zJAdDHvQL$}7({6U$ZeX#aot^Ir zcQweY8Qs|^9QR_o+a$huynkdtE_Syb8e*FsV&xq!_}<^VfQ?FP->UnYg2&uj3y$q} zxS4W?o3Rwf2%B5Jjm>gXyY3B*znMAO$h!DOwv&OczmbLb00U)sl}RD4(==bCkTA$J zE!b{iN*SWx*Bh9&8Rz*V%NeH|C(@xE>&R(x^lKkq-=an4{9M93L-Kft))HkCV4lw`&bXzgY-cwp;o4xH~1$Qx}oXPJSVxz$>HWzGTDQe?kE6YG_ zJRgVQmUgAo#$~GCGPSjNpf)a{#<%V6;egt>gc{NIJMhH!78ks4aDzzHg69p+klLtJ zP56D&fu`T$=1kcH%75;bZD+IG)vkL-6YOSwcCrrG$@KO3ND2tRWBr>G0`wvQ^~A`Q5y8T3_M`+s5`F%{$WSvgv?gZCv{ zWp*^H*){B*WNo}?TlBoPCQYw1;i^qvvGcCh)+_aGm6om0enV>akVkOS%NYjS1f!@- zPcrp#w)$qyxuBbq7EZ4}%NI&FC#G*Uot@VmLN}|iAbckIjNSVeJDK}dw3+!=v>B>g z%p_BfVykXae18=7C=%D_Z97NKHTqnV-mYKq+Sh8#iJj87=m?{BzG92@qGz@H7Ox5J zu<5#M^=)@^LEl}vfS7f$wqB@ji?nQk_P01I7MH3b!@&_r@m4@I=q77z+R5lxG~)Vs zNmV?Gr_KAy&c>mpECT1dp4B;N%L!2z$#$Lh{^b_tzJE0}Zdi0dELz_s)!MX4Jqj1+ zG?)B%fzH7xkBrB7p}x{kn&ebRS+JeZI|dO@gS(3RMLS##-l6aNX>A&R=oQ3$nTft) zXI-tWSL)j;EnJ~pt#dy@nGi;`8k_XtN@A(p5fEp(8sR5-{=#Z`g(*mqij&eOcmrizXpoG}pMwFHf=iqS%f z;G6>%m=vkBjSY&gl4wEM#rM&|x$#0f&TOaxH?f&dPp5RDUDAqq(|%*)SrQ9u`Wf*w&8=n^r)F24q%nHJhJC-8PU zAxT!2L>2n@FoDVS@Z@^p$=HNL7AKS(dM5J*5p?b$D+PEW9rjETj+x zh#7=p3i(2X9j@?v$bF3P{)%D*3Wks(AAb>Wgu%uLEfyp6zG8%v3>m}(n3;I*9Y<&d zIKoh41C`IKx7a}H2`h;WCbZa~!x;&WdPV|UaAL*KZ(~ps_{4;~q=e1&mN^9Cu9={a zE&`z8f`F)?BPlox67u-Qfg(A7tGjQE%x9^_D?08im~zUKx$tcvTWCywPb{zTX<& zPnBhdsX)n+reaC0Iv{dBNY&x8!++Ph>hLsPS;}NyZ_S~B#C}kDOi9flsUMV#4aizT zg@A-`dFx&>pvVo{CNuHbZ?hx4N>U;2Vk_V-`l&gHD<+<~i-EQMr)w0qnnN@$c&n39 zz1zkGDizhc=eS@dlu@Nis9s>$cdCri^56i2RkrOn|EmcNU?-UC~g1S#1E}gwZi%kpoiL z!wK#KguAN?5ERvh1cczO+8_uJS}Z{5eFX?y2%#M`gwP5ggx)`dAb-(Satt8^CV-Gb zS1HgUgD%BGc+~O`iq;zhNJ4^+ke7;Z*ewQ8Z+L%&^#&E1FeD^lms^5W1&Eq3fvE|u z>J3Vfp+Qm;R6!qQ)R`W2x&2)tJDxUjS+4_KHB1{d0JN+ zGW0@bqc48EBKjghR)47>GaQG)OJL9!dyKx=dZRB&w3B)qlpjFFp(hGMA(fVa(HIlE zZn()RL9s%obFaKxR~xiWJI^Os(!DFGzLn!kq2_qWAz`=%8~Ys99I~Ja`68THlj*uS*0Q_d?{I_BP@X~t8~~yGRP_& zbvF#j?9aETl9AaTLsV%=3>M(<#a*Bf*>sLK{Rt39^syr&YImIj=ciua{M5Q}e(D9z zPpx<7r=qG+m4AT*J}zPzEKGEsy&pG{N@`K1<(vZ~km7_fM^xz||DzTiPP4vD3eC%% zw$gI6GJzDXS5;{#i4w+ARi$JUK!zQhs46Yv$!W8UC!o7HphKesKdMR;jD0M^^PNNr zE!)kZ_21}__I)JQ^n-6cV|7)fmZrftZ82t@>UByEJ!rfbx8D5AE4!?8X^ABwa#UIU*1i*YNHN*f65WjpusetI5C{zO5M3cFS z_oM_x?ri#;XxMg!KaG$h_cm9i+G<_LtC^tKkeDoxIr-Sbk`LKr-T<|#hm*xVI06(AkpgQ zrG|)bG3u_vNHK@7k4>wUbKt?H*Wq_LfQZ&;AvU4pp9sX8uE@YOBUhC~$BfgKU!muf zBV|fgn{1_?RjX-}ipI588GI3xtba2G3HN5D(XKNyE#wK%Y){3=&7n`J5*XujSG2Aq zh_nq&9lSP>7m%WM@CxIpIt1S3$Rk^mO3HM;{?|gw5XAGeD+_Qwg3Wm!9Oy z(*^UF=Ys{m;X`JA15*PCgelS{$?`XHqxW<2#yn4IIS?kr2ZPQ2omVD>TmmQogpmD` z^YlfT5hX5n_|*t(df(42#j%){5jR3843M-@K(!Ce)@Cgf^WK8{@IE*9A!Rh1=$bCF zIK$M47ke1*G>iAXS7#l~f`4HxOX`vy+Um@-j%zXNxSnnuH!ZX8ApC~&-DtPxbjY4# zxH@YAtvd^1#9d}$goSHY?p*f7p=-Dtj63%sw1R_?#k)(HE|*-SD|vSnS)@_&L#&nx zxR!Q_ttY)mx4GaAExM6IXut~80AuDXV1{QY0~(0&-6e>@TX`+Oi+@CDtmxF8*zuny z<0nX_269z~j`W+8i5q+;MRXU&W;zI8tL+^Dn2AOc<4Y-+3~Bs_VKPC=LhMJLWY-LU zW)+Y*1w~u)q)4MYtZLrz!N1aa?O>0+1keoG7}We`Ynd?Tyu=t~TALbGO7VVvvV_n0tcm9lQA}n0k@OAHl_jFvt&2M1quB{ zK1UZyH^0A=Gd&^(@1%wJ|MHVtJv)DYx)P;mkdN_s{8tGUQ#W2gHq}t8F7WwgRY2zR z@0wCr4cS2{Igh_f^%$eI{KR7WuTp~qiDHQX{#8+AD^Tv>e^yi23hBG|XSqIiP$w?A zLH?-NjK3WszVkhn>co#GID|X6s3sK<(s%EB8Ic~?i;W_-@U7e+129DVF8%>glNLUI z0r#_(K7RoLYm*j0I~f10NnvLb@`HjBX;$Y!_^xp6tr7dY{-~2_Kqh~^RQ)s>OLc(6 z$o5DdKe9?jU*F2qPa`7_z>ESgpFo(+A7ygb4#-mRBT?ZPC#cG%>N^3d!ST3Bw*vj5#tOEbC&{Z(=SAl<7=<3w8 z8tbgoJp^m%xhuiHEOdV$phB|nS943V-(SobrurhVs)awIsM5sj83?WXO~llp*`K~Z zOaJKfV%BZ_m#4ovTmh-zg4J7fi$BRTtuC&>Y4ugLE&o|6whkQbVYAe65`R==TEez+ z4aJGBF0OzyBx1bEW~pafE~@cJA=|tr<+4|w%>iIQ69DDulShA^Jg_)Id`sNn#s5Y*!c?p0~1|_H6_+AF+ zPKNrp!sb4P_+1S3afN_q#K8lu5EAT{+PDJB(D98cAn}C+SJ3+L{{G@9HdEsYYi_xK zTApg=mptI#3r_*o-{@a|*zWLVP~KLsLG`?9u_vIy+K5eB^(rep7yQP63!v0Ok`2vnfbe zm_X|n<<0n9a-0DgWvCA<1n5o%(tUg=H^vYjp$lLkj6uSq3&H|IsmR;5{)<2ZZFqzQ zxiQS^kV7ME_G)UR5jLF_6bXX_!XR-lNEpme6s&+C*i8(qq!^gQtjoY}(XaRy&}1-3 z(JzpGUps%VNa{J~k;ZZ>_$9_vT#J3Fh#GDMzr=K*l44(oz6L-O=;1@HI`OfcT!5w) zpos-&TETxWMQ^<;livf z>zjCqtJLt4u)oa;wJ8?(a5TFFdR+t1>JsR5IcI-+ju4I^!ua%(K%uP95y<%inV(*g zD6phCLdrKt!EOYTcEdX7k*Z*MQ10;7bE}(%AXY z6!3qdx%to(w#Y_xl~Dy`+iT239CW_ADiNl2 z$F84-!CydX8PR7xQJ9W@%Y)BMfTk9pi3Mm{fi$W6iX{_w zr6+|8&a9$O4@J4UUg;^Q{AD89Qco&9WzK(=b}fJw_A-!wvBFj+E@WK6PNtBR%*{py zO?NP1V^jMWOyJl_2@0{V5Lj1`WbVIJwXG0XR**#Tee?y5kZu&DH^@gKh4u%H!0r&R zN61ey1L9yVGUO?3@sY*rYgR_R=dEKB@xZop+N+f@` zPHbV+nRcOMN|iWIOLhqYS-;c{CX!T5NF^v#S7rX3R!wcXpXMdHpJ1cU^HS4^DdI)< zlSK&%7*dArr&|fCWcO3b+EZ@A>7)e3gw7Gg5)Y~h?5-NH0N|knwdBdsT6NTX_1ICP zlB)gUvWw87T=>~tMdm3$1`Lj3j6| zEH&eoxw@PQ&2-SiM)sP-ys381)I;|0`_>t*@x2ENy@ct#!0_I=mEH(3a6^CW^hQ*3 zx0;rEBhXL{wACBYtk%A`u#y-d;`oT2gok`8VJR^J1@%E&2^Tq4+S`p)nsQx%3haVf z@Y4jSyfxWM_3>ZdGmlJ5gFrvsGeyJuH2p9W;st8!C z0|+h8#zg}2iDoYr>cs9ib<3lg%^PGGBi4RC{J1Jf!TofeTtd}~Vfue@MZ$KH5Zr9q zB;t0HZX6YBEN0L}?ZG)e`K3Qs#@?GqOnoBAZEf^@dHO2~+Y?syI% zFe>i01VqppSK=H(-pvUB(a>xS(dL-qj9DA49pRXZBC_zrUUh>Dug z5Th^1WIYOn?HwfgcyNDrsvWk6_z(kSfcJxNYMHzDW#mr0#C1jQh?_1J!CSfMf)Tun=)Gk|@4f5jeHjOHmx$2O z{>OvV5^{DwaOM@jSp{%TAwHwvp<_IZnf^jS>^oGi=K)HiVCjFfr~1)5$tAaT^q#c6 zy+!ZYW8<0o{NksH-boTz&3uOTA6SI%bAA6oyk9#ahn4O-I48@zoVqcP2c|Xx*cq)5 zPJeZ%JECp{#O$Ex6|b)#PL`qdV1FwU4@Om-;@tXX8RzNm;@gQ=vdkD2IYU`AS@oWD zWEqgP4U%O@ob7+=vbivgCJc)#6ENyekSG}xi`zR;=PpfwE)=&1V2Ek{bFsL+TIaAu z-0qt!<3Da!^bNN%l_j)oc(D%LEy8xK!cr_iCr0f_o!YEXdro~?!s%~ZnS>s#Bx+~r z&^n6RTdSzOuOGFGxH5>?%i$Ls6jufmZY9msjS!M)5(fc`hCCdaz+NJ~PCAfsB zPqGX|@xgzDLmiN1cG6xQD1zmHEVCJ80EQTD9jXQTKWFZHXb0M%+GP-yd9>2%G_i{t z9e0_DMLVOmaod<4KDH`sA9p#|tTuA+<;utX4_@u$E^d;Ms3&48H#%;fRET@tS2bcA zA=WsE8QX|zn=`K}K8On|29>PhtdLe8=KnOdw2Ob8y0gDZT78-A#|DZK>kZjNG2)Sn zr=feY+{SgYxng^AIXhQmra45lWtY36{rK_*u|>885)?k6B{xC@ zO%cKEkzVZ72s;5A>wCmRv<1ykTat}v#r-(3@n&I|W!#S?4x6fGk}gXt`16LW-i{yj zz2JW-FN?!+#_kQrJuS9daVFMUw2|B1+k$bKt4xQb-WKpCD(E4ncwC%>3Qo7*_OSi- zY-flOg}^>9mF&Y#dRX5qP?Vyy)XwTVLI;+8vh#E}u+6Q-?2;}|FQ>)+JDpjXN#WQu zRZ@|SO=p!@EQL+qaA~>86a|x(NHMA~X-9u~a2wARCrGZ??~lgOTS~S#FGB(DQIjUL z%M7-ZS=4a2TPY9$wsVi&U#$$#3uW&6)v4lhQ+vaG8rz7OBckXZ+LV9 zkESNYhIlkFDR#RL@AgyfKD^t%UF^dFYhHn{_5dR79T91t)ibE$@56ep>rU%5wzMk z4J$>^+Vi^VZomMy`=r?2!OU-{)a|1n%mJQ%N`h8kceJKA;695JrwL(NCsUFt1`Fg$ z@FH+Q*%JHWuiL`!S8wtqw4$$xj0u0G*h|?~zxa~LFp>WFT)lWLC|oB)gijR(Kt@RN z2a14c1C_D}1L%Y^4DC#~_a<(Qb0csire>i8 zRW4O6DuF2lfRx%G%2%%-3aoBn4xT_N7gW&XOEu|Y^aciv9CH!bFm^BA*06svDz@Dc z0H9BmMVUtkwg3tn&7>LKJ22ev9o-Ey9qf=6<1$QG@QVI<2Ms7r<2s9f3 z#in*}7siOM1{wwd-|`*Y9nF6$(1N?eoLtCQg@K4ziAADjl_AjJ)~{Jfi|TXr37M$S z&IK~G9LWr-94%QYBdaWJDTxf%@)%$1%hXDxF(h)eB1sGvu^uLeDiV1OKFYN0Vr&DJ zc3qTdlR~c}kaUcQy^dVOdWhib2w)zAqOYS{nbt~H)w^VAxRx@lO)GzzMW|;~qlFHL zl}e#b@Zt)k1=UHtbbvdq+2V%{6-n{VZ^xk?0u=41>^Iay$om3iRg$V|p_*;rhI)`f zzW-1!paETU~2laH?Y11bKLRr^8YcXD=H zoL}PBZE;>HHB|M%6@7nvmu%VH79$uJI?T9kY}aEYTf8va0E+YuO17X4X`G`lqK6qhDO@?^FJoeLnb9#yqi{3vPHXTOO|X4m1{ke zYCm}`)l#}@jY_qIuG-rB>&{dM^v!{lqM%@JVuGXObQ0Qm1#y3Tgtn)0uYbkXzVfbn zg$hlI!KbR)SAnT~73`t~gWk+_xM1Roq}74ODtn(nmpr9?H~^KrbLzSMqQa@(^+nEdV}80ht-(auociWdi!s+a}o-P^<q+%q{RdnYO2MZoSO5UG{CcEb%BFqR$k!w@nf( znDmZKjU+v7Jf@&ffu`w6meJ0t>6u$T+pTo=C%+z2c!+<%*Yp(0Wm8n29!e=i;=W3( z{PoO^sdkuvc}4{={|4t3JUBwPaRKml*YMT>t#AR-D|IaVodynMG!Bn0SoUp4lMHP~ zGn)d$Tn{Qht>p>}=y+>+1?%Smw$E41Q%Fh&C8`caR?k=~IaM3-p_Jkv?)ub&HF4DU zsjVL+02P1zfXoc4>4yOrSF=s42Wwa@^>}s*CP*B(B~gMw$I80&V8T-{Q5lQ^3Obd+ z7@$x^WzY~%;9chWXpM>b11=|Rq&5fx3vP%CM|ou|MNN2A0ST;yq;F3Dt)saGs{7Vq zUZFm%vBjw+agqf>D)Cx-pp_UTeHYa&Jp&O|P!WF>UT0{6SVs#cvUPUBLJV-A_CS!y z3ORcqN{|)!m%2$5Q%MV=UI-Cs!Ht#)Qb>VSOGalZx3L?R(Tl=FmT!dlb%96IdM$ND z>|mV_Q0mRCa9w=V0oP~oB3dVAQ_P)<|IP-`F+A9A*VnTC6_k;>FsmLZgiO+{D{R}? zWb1!QDcjgAtt&T?x(LNY-m+p~8%u0i@n9^r?a>8_L%5j92)9DWYBH8Xs3o8?3-o!_ zlr=%5KcWv~8sDlXWrE7Hg!o(>Nz{pZYUa-6-(~}l`5UZ6-D_E^5lRc1uQlpS$isFz zCPMZwrejiyJ)9I6DWG9;vzp-Aig81`w0?hyf<0UWvqRuUG!#ZLQ8%F-Vw)DYuDBzr znL#}VS{%$9R}M!WZ!c9HcJnYg&(qs zE(zv^EY&4}y^zc5l4SBizJbv|{wwo9w$mp`c_8Q9VtKto6L9$PZ18B8iQRvw-m`{B z&(-i~2dv@oQwf3wL4+XLP))o>rZzjON!=IH@K8|t;u;W&i)Xgp#WQKX8-vri;8m(kx75z0{n3aeFW6D(MxXQ@ zm1^{vcSkK?^yyDL)4@(EGx&d$?W7WePY*lkW}|ol_LaDK!wT$uoE**Cvk_*`HNtHD z8({{SjfLNxl&4N@xRV;VRV>T|CY3uWCKtuwB9r8zvU8A2$VIu8igLN5re&g}oMX03 zRA#Pmt{wGIQf!`(GUbzF4aT0W!Pt7&VC0H3hC%MAiP#}n?Wh*U4hw%9vq%#zlg2F) z;4l?+i^K>;g#;LHJ1V7!YT_1&a+S1hk@sa9xhHTV_auG`d*Hx`rU6{fQL$$`Dz@Go z6=@sipbDtzt<%s^@of$hgtaJ&4L?}xsE8-1B1c6uK`rU1Xxo!Ryu@{Jk@+u|CpCNwYUFM2nCQ=cdAmC*n(Vvc!B7+)^ zl)b4;e@!|25t!D4lJ+CozEe-ueuR_*0BQRX6~w43Zy%hC+tZ$mSyheJVYn6<#!Pyp zm&bL~WDzr20Z@OCz`aE0g(P%eOy?!H;63p&H=yZ~*KZm$C4nYP0N+v2gownWH#BGk z@aqc2j}TA;5Q`@{c-Iw-AAuYSP&9tzDrgcb!Oh)=Bs2F=*noqkUu8wR7OmPX)KSyc z)%#V`7Pj`rn5matkLMRZrHcq7C79cp&EnhilI0_)&p&^c)pwf13Ux&)wBkQGh?tPD zD0&|<8b_<3GxYbv9*MC174uM;$Xt@0em&5M3LM&L`UBshC=tyIPA))G3(&*@G_7o) z&thR_$DSvA;S4b0ux=B*kji9T=ICYbd!rU>fSX~2Ev9fYOyL&O0_~-@&?IG8ZEk17 zZh~WZS3 zWTYas7)TVi*k!0R$N5t&j&rnBain*Q+@Orb86AH(1XRn?CevAa!dRU~fcemg^-%l} z@U5O;SX3rj0j3t9i3Mm{fi$TiQ47cy%S$-s4KM)1Gb{sgVOmE^%4%>|P>X{G~tpRX?{hyG*H{tIRGxIaNOg zZeUOK6M3lbIMySMV`3A}dxx$fY7O+cLk;vGHN`FxnIt^glaEm`iV9-2E=E+|giL>K z+$IgvMr!hUnHgABzP!u~F8iKIrq7=P+PznVH(oVNm4_Nb-mF!SP_SPm_lvbP_>`D~~p`;2Q=tmp3XiL#4T(MK3dX6i0R!}`J zta$E)`G4izX{vTkQSeNatvwiBLFg}Pb~Vz0b43iVczv5nrlW8t;EYq-L|TD-O|h$i)zfFGRP{lRjuOUDzJb4-nPUM zU~6X|TUBc78QTf=dEBZb`hJqF%EImV(IU56Hzf&rlDJj~oZcjs6+-qNr4m*M%u8Jt z3P{tWOC(gXo+Ys*f*TYts`hnca13|(1lUX2!%ii0s~1u@rgb}f_#2qvPG#aE{AiI> zt#>Pfwv?3fAxDpl<$Rc3m#lxJ^Pzj!!+WpoDm2GKscxCr@sOflHZS80IEgo~!2ZJl z=u6$JmwCqlg*%zo=RgYY`JUK|4iz{bS*s)JSRFxevW3EJHrv!gvj(@4^ThXP= zIaOsx#GIp7b}FJufyFGN3i0|4(XHzrU4{^St;uE+qMMV;3?g)bK3l;EJ*!R||5CR~ zeLPCf5}`A7+ltgbOvLa-I%oUB$&jgKJSNrvsAa)81L<;PAPbu+h?(StK=+(1eSud% z%1dBms({?89|9>1r4E0(ND_lm`S7q|iKH<`I6?Y!_MU4XB9gS<1j|epL$YD%n|Eer z3Qgw5rz)u?7Rjti2_)a1bt9UY7@!(UIf=<;juI_Y`Un=o9-XNlNFkPrr$UU9whR5)O7P7wU?l3ppdtq(8TmdagTUag9p z9Ij5CZJifQtkTLFngNo|2@L`ncb1)YHC~=)Y0rPMw3KluEHVCpw3B{87m@Z&ZUtAC zwzx>Efu$u5UYuTw(`iv)X)WU_a%~D~nyYZrG4R#}!hnC2QwlueEal0I6NsI4-evRt z3-LE6jA1`O=y&RWMuGq=vot1iWh^iyUm!`;H1AlywQVxhVGrTxNzj^jW zv0KnPqYA_%E<3aJ{EaK8wQ(_;ts zp>TgHBCeZZLp{WFGN6X!baJ9InM%+rsacxbbjnce7bWxXtLD z)pDp!;0+~9;5!vf#}W@0`g)KOEWbI_c4-Q<9BQ-7#8N9lmIP#K*f;&;P+^<&Odu`k zs7H4jKXC9%n8d}Wf$y}~cUt&6sXU`d4uyYs@)2D3a3jC^bts+?MVhegGKVV8K=184 zde=#mnt}eDL|F+Z#Y$09p`^=Fls1PF07yW$zl%HZbEpQ+%9d4fsO=@Q=23Z#1BaYT z;f{eOxfJFQm?f7ok*w0?QkD`{4s)p%$ff#Bxs<77)~7P{y#T%l0epEv{B;Y^)02Ai zQV%bG`tZBRr9zyw7^39J0MHmij9>{qjd(5^#yBOC_6(NGrFeN!GX3eLc@Z<6YrW)B zplNK<78ZA!b;{{;*UUToTuLiCarbJbr}W*eQz@-!EZo1#Hi-rMbuXB4mZ=md?4C0H zXpo!^KDNq}O1&gYn~N{AX*nhRm$IZ%cmAn=eQDz*bt;uMS|X-WWRDL;DwU9{XPBEv zQz^Q9eGzJ3T&!v zQ~-F10Z6dGF0vp=rFMy&|5R#IREefIlQBS&?eRm=(`^nQCqsq+911?lQlP6j$+sMT zw?cPJ?c=0U{;7R@84cr9N-d0Z_rmR)sdK5BQegr!5#%aNz@~xZTuPWnq1{p7IR$xC zldUZEl1pXIqi#7=q&e)F|E6$;M zbq>{f$)T7M6C)@oXb>F1M07O^!0Lytapwz|Q)-_?V!|`EuXqkM$t2P@RVz$Pbt-+Q1s0ik zFGTN)>YkgZ_8}RSonjxP)>m4oZ(E`=)ow@GeU(-FKD$e)&MG5DPQy6Mk5Zz4u~hNX zG5u8PeO3%VXDI;oTVcPlRB0}tKVfi{0&FNjK-d`<ND=6#))j5usL_IVP?C8;n z^P~jIbP7v_qA3+61A@{vu4*4CotnE;>_cQIT1&;gg#2IYylfLJO^Vzw;LLoaBq@iH zs@=>a=P)t|goGJ1#(;5w25Q!SAyT;$WF1U>yR2Eq%c+-;GlrSQ9d2w`SLg1tgT5k` z)}1ixNVyqedJX)nLvJUd$vRZFB95|-gr)#Xn{9;fj4*vRxi;I_QQ-k-u{h?*On|yr zkBDOdN8uwQq;q2l4dj@wQ$6dz^cl!1cOYjF(YXskmAgRHXPDR2xa$Rf8h5QgEL$3ojv(bT@t^XC;=uwgm3T^DzJ};ScT++uyTI#8D<>@%4u^e5g|B@+a@9jVws7& zT4HDeAYGv{U?a>>lG($amIK53*WJs2ZAe!=N`-2ZWG4?O3Um5=fVAgRVt z0szzEgsfT+imFi(;(2XJwS;oDnWS1mxmql#R$Iau6feh9ms=tdPed$a$L1!oV?)nF zz_xXaab>AN2I!A})zKb1`H7Z5BC;}V`ly?S+}-k!oOuYT>X4@(LM*rZ1O<^oc?hDV zh3aSxL_;e8%4gew??ie!OE2b|Z`BkR*G^Co&^ZMtP% ztViZF4?&fL2bYH=JaY{55EIR=sexwKbY{?S4rL}H@h&KT0fAUp0ue7o!#Q2R3ra-F z3wLEmKH!e?Yfd@{>46~&E1CCjSy(DjFO!b*94xG1cbo&i74|Djg{Owpao%GrESzfv z+5G`5EJQ3Q#llK+v|O;Tu#R(%h!@fzj&`q0M7-Q|oXb^e$;9g0y=`=kDoYyq6BC&673-V9zVKMl?D{0$4^!= za&!;@xo?Y#ZB=mEo^H5epaXsC`XD%v*gL+L5<%|hPDX(J>%l`7na{T}^NIR0v8$bg zz5S0e^MPeFP`wOX6(hCJel0aOeWMpw~PdkjR0xwLpu8iIFfM8Dqy+w=Ws!PrhwhWfZszi_Q;O`gTn~GE~ffZll{j5 zgTjbz6evNZx9fVd^%yGJkvwYCWL!A%YT1A|JW1hfdanM9LO z6MG1>9XSCv$v`yHxS4R`bOUat7>TvP%@hmNCAeu$-0ak<6$aqu-B5^|tX43tUn;u|3^-F} zJ&ITx*eL=z5ODME25#z^n|bu?lsXa$Jv+LNL=omDYOw=?77$>8Fy?TVAA+4v;tyPa zn|YasK_*X5JM047%*sF%v=!2gb8CZtn^_sdwmOtl{g;JX^IxXQq`)t@#U(3!fSXJ( zCP^f$&)np(0aMESwZY9Knw*CT_|+v7Fr@o*YlE8tCbR_@{n;TaI#48MeK0rEUCOc^ zY7flKz-s62@dGZvO{MGiyxbUZtG)W*W2oH3x*7F9 zk6E1)P|ld|`T1v(Hg(kX9J4nUw5wy@=dfRUrnYs+_uNwd3h+HYxkULk_C;kd6!ATW z&CQvhDC~C*upyOg?F7H`w5hqWy*=%Fo^o_owJK%?S=dePiq>p(gS&=BRH|K;?+vJF zo5d8eP$NQBd!~`-E}Al8gdVnk)5jA>To+?dQNZS}l?00eA{QJ08Dlg7e@bQMRKa9= zq2o&#_sb~nrGY<>vR+mY$pQIZI4mIuRj{Sbbpop1vU7QLFf8NK~uJMnL@VW_# z=3z(C6Q}NBF%YJQ8cZo2vS9bA`_@@mTd<~mHm`YUU@CRfpxb#h*}O!5m#`Rw(nuS2 z9TS9_#D67VaShO#oK}gSmYG$E&ndt>)DAW(2G>9dm}JogJbg7)w*gNLRMq2Jf-*Jr zh?2UpFPIJ_sr!Q)_-xy`6z&t=UM^d&@ZtBb*Dt(qs-ljG2wJJ9gJOZ^{li=YDMKGI z6G2L5PTw7>s55$rH9fEtrhUZUeD)<0O`WXz6KS4KskrEmv(sn?0e^4RJHSQ=Qy=L9 z8zEN8NGI3`=_WwB!AOLLKT6z7q#q2EPG_mDBW+}uCe9eq{c}M^0(#G&n}Epzaog2! zg(QN?e_a@98K+4!@aIv&2=wvjkm`2`rVJkixEf~%>%vGOPRR^_OGyAYl>zb_=`gBg zhY&k=_Xt1gq$3P<)PDqN6No$JFuK8zO)_Zu-0mxSc!D*Fpi$MYNk-y1cshfTI*1C_ z#aHbBt;vD3;!aCZtfE8jp{asCA?QAmhLPT8@>`v%JKF|FMiLmX0`{vm=BqK|)sm*G zTj1#Ag2Un*(K^?D+&D)x&XLNH6)|JYWyA`bu+loy9?QshO@Cqoj0fpQNeg%|8pb#9 z8G#YN=Q6$qfH*A0Y85Xo%?dQhXJ{R0l9w&oyG-ktpMjw#!|!CZOgG6;4v%rkI#$5xRewR ziO;2EuA)$5Jb$);LzSc0Vz?%rlt#W`ns}z%J%`30Lslg3;2$63)XGd%d=PP>fpxVGX|dgw3mBWLD`d58W+pbaI+6B9sxBY(gHb7>0w5dk`5=#PX%I*0yH z;eD2h{|HpA*>l3E>h>c@0cfXiUl;P*KnG6%L=eZQRsgQl04D-rzX|+A9uR`&2|*nk zK!2T88i#O3Ad*RoeE}h{)Q0}Llmt4Je+81@$ZmIZ_8gt-RgYr`tF%K(^gQw$#=}D> zcV3{rmVd-R0~(u|w6?v;E0Zj@#CdWQFr*&W@Nh>MG(_|}J@TDaL|=3O{k8m(PM@kV z`LMW5!9rsu^i&`6OF=_p2JBRyOI z=zIUHzah1Vrj*7~`d_I2zEoK(A$*4D@q01-i|Sv&ZHq6Hj(-j}EpBcya5m^mOQ2zK zNo-eK47`OFF+D|kW6{1^l5YCLzMvPZKhjC3qpUWJY1iUhm+~6)mDx}#qw|!y`i=HBJi$%{{f!OLLkh>X}d21 z{85WX4qJYM2+Wi~e?0~IYv!{ljQ!0`uzx=b=r8smrQbIO{at>xQbT_b@W(YAh+Q-S z`{gwlz@Wb)4pmMF@VvM91_M%>WvyU9kRfLb17Z-M&R{@b$w^g$tJ(Yu_4mR(ygl4S z4BQAfA#RpPM!f46PTs#oMO)7i)OrOUiCTNY37yD1l*GjPUZ(l2hZ4FLPu$3MP=58;#IC+tNQiphKVM;REVk6TTSKHqBZh(q7(fY7J4Y2Vp)jSq)??p^#FA@Uhpbfre^T6v)dmKf zsLdl5YaCE*9-(jjjVk}iT7P58fAQAeh&2P`@_>0paQq^ODB!70g|$D#et64E7%+0> z96i8!B>?XR+y{)v09y3{!!m$aAFwS7i266A{UHP>;F)PfT@;Yp&)Ne0tbaY$&k9@V zfp>P-H;a7HF$y?>$S?bsfs6um~h<~AiCt;$17_#aJ1#Dr(O<15ap_%GK0(cKoG+0<0xQ{LY_7#;A@iiM>C&c zFyPbx1{|pQ2k6|&00NmXAaLP0Auyv&?1e?FG5mLf{(cJgkAw%SjDG~EvxXIr0R8s? zBRMN}ERLR_eZX)A+#ttR9{PZ@yMMqW81ShKWB3ROGA#i8^}HmmMLq{Vw)e@X3;pHt zGyx8b5EX0~uTLL!(Fcs!#suJQ!Jk7PLw7JBcL+b`q7PW$9|3tzV3rd%_m49Snd|<+ z@?pWeTb6`$zL5KepnuAfAUAuk{WXRDTv`A2cF~SOJxNNI)X&q zJem`4kr=iZ?M*)N7r8uQ)-HTH&2wU4%qm#q69W8-5Lj5-D}UsxJD`xq4OD??C`a#{ zPiJWxobV4py*?-%#CTIPDkeznB>qw+p^mly@8p8x$#V}LY4MYd5IJT9xwm5FyigIXmL;OqYSgvn1#VzoAY@EqBqfMPVn$NIp0_|Wj0gm*!h_m9 z2s6;T5|4lxn^c!%blGK2b$3RWb3waXq!kQlDWkQ$H4*;E*=IuM%Xp3rr>-&w%qE(# zM~{_p!n|mR+h)1e-Ea~8tqa!8HD$XBDv81&Q`$i$b$`mla`2RFzSc1l%kU`~Z|!Wi z#mmQ7ahFjm?o&1TCQ+o9m*78Flj6pu#zR7AyqDNYB7)rsz4O}HZs2#Z2}-4?@DVO8 zg=iU(tQ;Q7IUG(fhr=TL~$s#z~I{U;Zhi;3To)%R8CpTRWRJi8Oe2 zllgKZf8Hv-PAa}6lcO$ouDdPyax+~ZV-;6?$3SIQTk!=`z~lVXpyGQxfCg|tFIDj! zFQb*-&DE9{>9t+}fhx0lec3LWqZ(FxwHdD_+m#xnODn#|L(^ou^|ji1%s5zXn#}Yf zrdTY)UeI;3+@4=GZ~0a8R#r9d4XWm!N(mmMyTq(0)Yj4Z!il<;GHd!#qP{z zf4gM&U%<5&IsiEU?}vntsG-6&bmZu4`3k=AYV3vHvYx@qK?y6<<<5h%exoe9SK^#Pw3z^_g_I&;%%90!*<(0!XQ+xgAiXvL5xE8DOCaUd0 z!dW9~m7q|?m+d|P)zLZH`M5&9-gDrge^j+R0JZV}*0{mKc)%Fr9qLuw0X8mO-1)Y1 zQ1O+N9_3}uq6N#6Y-btS7=a5oROlq-Iyh%L(-VAzVDGBp3#>3a$O4&>B-bS=*>{<9 z-RAvE!Izck3aG;Af^T!4)ou&E@y}QEOc%>c8#tkjWxH7Bd~xvxpl3H1y~VZ#f8QmX z^q!P^w|{0*^^GLI=g-8IX1q!cFt6&X$a*CiudA9M=2|6V-I&OcJ(vekZ=Lyc@^@DbInrbQ+Qm{tDW(JI^%H7I$*VIw(NTli@pah zneSx0O4PH_Qg{!R*5PtIS^~$bL+8NJmES&Ji7YaQ)QPqWE7F`&RbGUr&1>?MbhgqO zLuK8tv#PbxB#5FANiUQE%gWY4P|ucR!f!}b-)26W!m055liGD7f4!A`J(YdmlFGih zliioP@64+3OVx+s6n-N`--eaz@^zho{8U!>jSj52?1U{^_~qxh?1JyD*emfnN5q~) z#a{C~S0m_*=A!yyZ%XpnPo5j#Ry%f|AYQ}-3z$&9AGyDeS5YYbS6FWV=5g!p8pbGAaoUO-+P5~u4yaXL`xu1(3$^{8@P zn>?Cc7d;kxO|+XSISiO1R4?{QbcZFyUQ{k@;Kg2k7Ei$#JQRCVXal*yy~k%J$$#I` zM4jhg`g`|krq;Q-`$=lO32A7FG8Zg0=n7Ts*mLtx%Op`SoN|j+KP9xkIkMp-*>VSRR&v`N1P1U>s+<>6#ZE|Hw=C&v zS^k?x6}wQae^-Wl1c}Ghv|ROCLfsE!2{K8p`zuY#B!8Onp9 z)VTrb&6l6;=8&P(`;yvj6`Uzj%L6O*-rY*QIZM5iG&fa{%}QxbRH6rv<^sN{0ZlCt zhtvkONW~0LS?;ZeyWiJOZ_AV^k1C3sLL^+kJga-wP)~CUl*?sZm5J!$=Bt{CuU}o`@saq`z$- z)q1DRe1;{!)00VfBY*c+?DbRZZCg^cH+QzXF89vX6<>0)n@U_S6JJWX_o$AWD*3X? zy@FydBIyh#&x}&<=(uE^rQCb+6D_F@yW+AhoC9Bb>6ezSLJ2Up^h-&8$4{84Ehp(! zlzj=3&Z(4ISoU=%>D=?nmaVY6Qr17IEn9xBRz5#^$T$V`Ab%*IA7{vn0(u~p&p(w( z+$K=Q1^Yk8Q@0*F6{t-mGp-KAa^*v*@_CDCzV%!!xhAD*$JVaH4<63xvGgm`ZWhV+ zm`WIxEB*3C!MIYtt)=iW)$X=X@+<9mR`f3yOM+4Cqh~5~BY;IZ8UFPm875VJr?WYf z3IFzLvHnMw34i~#F8pS#`9{BUe$vxax42ky@?6lxFB#3z^OUJeC(Z?392jc@Nl+st zdYJj&@_>9-JRo|!5=IsgoU-TRl=uKz$m64DLabCTj~2yJ zeY%8vEAXHQX_ibfXTqZpxjx7Yl^H0e$)UJaqEFW8AB7&27Lz7zp5SIRR`VNvGFTdw z-j$bBQ^oaCd#ai$SBNz^CQ{cQ@}PW`7b_ME4u8>%?2m&)8*nfobkHH$`sCpZaPA&J zt%hmUShxiSO>o47#!CSSdqOU@0R8So>C+IYn#~1A_}+~L7d#VBfZaw2Y`Q=|vs>4I zLqG)8r%#9s1FUPNE8H5kz%L7JL#B#@&8C1WypHm41bBDQ-kyq83Rl`~5NUK02pOcGE}a1Rjf@vk23mKlz=`=hPql zPG@85kAJ7gvG_TQFc12Z->HqxsgFHh{eSWA)W+s?bWUY=KlFOovl8%&xnwAO88}1X z@|FXCbF|RK5~|asF#Z?VRVw>=pDmZ*J=iad;5|tE1$LFH42O&575JN@h4gZW=Syw; zFR-grWjtIg$KXBa;V1okX@>5>@m0h)&5*$4ePj3p#vLaxwm3oC&?i1XH6b-^Y7)$wfPossE1GZcJuG~p?|mA69R|c!Y6Du|Hg*V#!uL9PuRw<)yC)bb~~@g zx7zLCfkSn1WXz~e7vxVt#A5N^#iYb^r?H0K)eZnCwEyRjmzf|=DgfUMS zXa|V*CLel>-=(++~iJ_h@$nz;HLo5`aZ-4#pqhT_zZRc6LXjgMZ!Se9>>dKFl*3 z!SCi7N5FS;1+Mx!c4uE7=Gn%t*hlYdzn`NI1pM6#0v=8D_xN)(p?_W?05D?nUIGx! z=B0WKUUB`^*U%NW$RcpnP5u_m?N<;~u$?1-OuW6N1-eB@w?g|=J*!6 zYP-9$4iNDuLbQjffq$bAz_r{xG|__)=(N2Wcoagvmb;%XesKHq0ax7qeBgLG2!LAd zem?lBufe;zeKEj$-a**ba`$2+ADsI#_$Mj}jN&sLj?Z)~Dk$nVgcROFoJAclP%f~1 zkotJB5YQF|v8grz`PD|kLG)}QaDkdYw}#8*Ms7Y>dhc+owtr)jye9(uP{xx^wT2Wz zyg@wwEne1n4Hupes2?eEy~PUzuij zsWcManhV5zv=E5<=wBf2+!2>yeSId3%+*9spBEbt0{Z6BNMEjn(bquf=ma`_9?pw3 z*s@s-O}p$P34bU3F>nL0Y}#c>FKLR^bwplVu6<6uQhK-+TIi38&fMCPS0mKH>f~0X zZg%nqOWnssiTjvI8ES+YFdYjcArCF-57N)$_x$%Umw-~@)lI(rsYT}U%@^y(Sotzt zLCg3bn%2yYfw}<3Xe#Lpu^h;xxEu(}1Wqi}F|iZsV1N2B=9SQx6c->#x&QB%g zwFDg(5%M)b>q89lPs&T4j1XwIIXfywp*x#a^@Njy#*)x*grJ*T>K@KYj4&jv{lsWk zmr;-)@f@a6rY-}@v^M0kK$+%DnCX&N81Kxdz)elW!tVStIqZahP8lF4u0DPisA=cJ z_q6*j7k`k`@z@NZr%UI*OV_{h5Y)HI5ER?4P5?zQZRt!PDP7~S4HTu3Tu(;qf7FDa zpoa#0VpK4cN}D{Rz@w25IF^tya#(`IBUkz{jGt;-LL#{^>}-y>nv2qBp1V3t^t_%G zWI%el3S1ROrukfz>g_q)+csObyfr*voVnE=4F^6WSCH_pT=qepV>gwXlKp2A( z6qHxEkZWRqxF>I zF@IzbevCakhGwf+lohjsCr_EtOTC5Q(bss$=By^mY8loU(%l<^*@f+ChNizOHkRW;}#kD)kkVw z5_EB>3r}pCAD-ep5pmH%`12>I9RU#tgnt1X%F*<@`>D)5T*W(aVyJ76d=7mKL9{0> zP93Vs*I`CY-4Ni36fVF5HTaCIK8=#NfF}@%B}97(z;Si`Tsh$hiewas!_JjBokTHsj){YpDLW)yF`#N%=6r%{*QWK#7n>{iyDK48mJg_M~xoq}87!@MKA^|Yu zKrIj?rVL^MqckRH#Y4qNof1RS3;FV$vp zVb>DwM<9K6&xD}U@kby@cYg`-R#|NPJmQ0y3HNP|jIk9blf8=e{4S@QXJF{4%91ev zo|B|+l9YhNsGd!mIaafr+EA)ZYL=jhxvXv(%M(^PYL|x0YP@n;j8&bkTIOf~hpU&B z8^AyLtzaf%W~(YD9vf$_WM*VpQ>vH>hV?^LhGzWk?Kqkc=Q4mc)qfpC1V#CqB>HCU zoS=jJn7f4eF#IjNAGam<@P;w&eoTpHi{u1gB`l7g;uW%H3D(ZyC@orzJ6tLtWxU`Z zC+I|7mGl5Oz}ikhQgW#r`%{Zrti+}zuy|QQjfwoKmv&iBGRcmclnGS20_thk7}{9}acM)A6xhk31Cv=hP)HRTIE# zAX zKbNYt06|eaLpoGJfP=6mQIDS#9>)mr<2o8keUT$ZS8lXb6@SwN#`{!*poi6yoN%IY zTBn|ArZ=Q%a#XY{&P&ctmf@vxwM;}hpJd1B&q8*TWX1;7vUO@~QZ1X3bK?U}j&}GF zD`ndf7}nv(u9Arzx`cHyfj3tH1)@E7E^CKh<(sJ3Q&jtep!3nacraic=7K=*S3h z?B1ec@b{p>KqowWt5*sCnpt##;!G$PAZuMeuNJSY+<)mp>?SS8GiJ*D)XsrhYq=$= z8uYZ}>{=1^wog>3ZF1vIt<5_#F{_>%`3PNj2*r$rXg)&c9zqRh`>gO0Ld~mfeS|RE z>N>4u1^k1qxNMAg2LZl8u%!^+J1FiOlrncV@D3taS?Bf+N?TahPmNdcxHV2|eEG5C zado`xc7Hru@GwlTJhQQ$q&$QswNqro#(D$^^g>qFxLCa*@BuR~XN^0E9XMYJ{SPP3?eUD|FRW0kUjIgRrdn?n4 zv0Zm1o)*{bury^>109xE&aO@?lNea+rqoA_Eq0a6>Jr_RloZ)DySinwt9voKx>vKS zTQBTt(6Sh^3WiODHMYfL?7>Yw_-@$MJ(tydtTV&pP{(m0l(Ky^7U1a0gM}<$VGI%b z1AlD$%(gEM35((;?Ic}4P3EcXa)fieq{;lQPo;Fu+yQr7Y3=JcP3CF$gv=HygJ)My z)b=TBcUOxnS<9`4)JX;0>bNqg5x1IJJ~rT1lZwanxYf4Wq!7p2gEpy8o;9vbYQnR| zbV;-DtT~0`Ogw8wA=yKg%;g8S06)0T#D5R&6a3(o!w;^OC66H2l>N(Nl_eu3y8VMY zge;lL!UeMsm`U8)af}H>;@)YN40Te(vt*!)0?m>`A`iw)oN4cKF`hNo@6|vL_}(we zwbgg&)-ERZBJCa*aE6I{ylhnDI`3y^wr#3`ul{{B0cck;I6MsUz zN}e@Cs@H&LO_b}+%d<|(bD7HYx|HbkD9?qp=t_9j3~637p0!Ax*O+H5kmwx@&$%9b(`Q>_XFTrGv&F!+yHueOys$Mm*e!Qt(zM65YHN>M4ywJ0Zue_5I!lNx9x{# z4asu>!dY>7t}sSv{WxtuNaVSk@PDjBe8me%8SNg`>~_PmmZ!#w_SU%*7CEoB+RXgsWR9FaG`21kQ>7e!ff7&p#fg-+_$y=-a7mc+JN>{@>5R#c~`e z6)Q5l%@tUQ+UZxYrmR7g4{7>H6@*KEK&exYwfBe}o)}~!&UarPp zYfipadcB!eU2jH_nC0**p;(XPd46mEJa@Y1dCq>GyNl;Jh8T?&^oJW2+rIH%T|vzA>xus@WDfT_ne}x}?H+uY7XM zEEQfp7Z=yNcsfX2+Dx#YQId|t9WwTrVzZxUnS1C6lk4NVV81@Lx_IQ)v?K;eS)`ip4^~XOX#fH5_&v+XlX<9 ze!-G@w46o*9r!4BDV>&BN|6;4sNE%Kz}u-y7OU1gJ85)fhlU)^y=>ggd3@KHRvvcO z;4Bg6-@(>~CPy~%rGHX3vYmJ|>}o{ihtHLvlLSxdlLOo&F~o<8ph;qA_wu4i!!6(F zWXf&F5^~kFnhDooD^E#_H4a&|{9R`rWH&k#BFDZ(iK5LY7gqRqV z4Q-Y~Y@s3QsCS+1wk@$OnHCygU^>O5xBA|`fcDEmvDY`hXDm5f_J^gzg=0epyACP= z=PrF36es&-ks%rWKHOph)cM4V9CmUb$lCBt!Dnan`$Vz5F~qn4`4*`y?T<3^3DMbv z=v-h43UMD+g@3{hr2a=jOTOeU>1ffC&y}i9Aura%`+ zGDA-M!DPdnCVX4>Ogw6CyN4tcon&C1s$C?T+Wae;IdC!KG^+SnY$R5M>>6e z$U!gez<kV+fo!(o0cug(ZR|a9V|-O!D=Mgv_{#J3((X8G_e3p zD_a;bDL~9~P>szPK-kn{gH;64QX+^hR|K&}z^456hSOdDGH3uqD2M*17 zc(Jv^Xz~bN9%iOT#4b*CeL+IJbW2fhq$6Xa{eP1Kwmf(b5j%)*1wYar$dTBBV{?Bt!zN?Fmr{57ciEgh-S| zQh%kO+SG4gPGYB2We1i`)X|FcT&WVOd8A(1lWRpjF4;^_I_rKlYfqETzPWE~#qoJ# z4{&s9gk#1c`Ct)CLXaj!`q`ov=ft08lQdtDfAT^eiYSGAx@(F`C=m%svW7zrB6Ow^ zd^>zVAu`2&nyo2f2>cCt+U)M@0e?OzuJJen^EV^dBhzTt@`vqU3T4nVEjUbm z;F^w1-r5UG>Bz?)eKx|C*6*VNJ>pLN?5^Tc9l^2kY!wba<5Xidk6;B)?JG-`ARw(N0yjSxN=#5R(%@JY7_An%kPK}kbhB) zo)jt3WT;vllM+H0QD!`1bD`n$8*Wk-2Y=30Ueq{p3l1;IBvXPlKD~xG9($LyfSwRmZos3= zgbf-t>jh0T-kCNztL&XdiCHi^1Z(KuZ!-yB_T z{$@>cn4HudF1tO@aJqk)$Lp7|o<+a?W*(6_9_{=5%EM$s-nbn6{;uvL-AOcA$WV3I zoZPPRaPfFtYv6ox7h3^#NM~{qVmi}13#`E#xr;)8csEW731j$%>>?gGR_|dBN=N0< zlnJl{-c5`{-b6wNm14>YrGkUtI=~&vNVNdWx`LE=%naEwTDY;I(0^!R5Vq3-lZm&9 z9L_;_9nF(A6MG9!tWV@tk|6t6`@W}F-7BR*_2K(kqRvIw+?=y+F3{==dy5m#2ZdGa z@$<18m%s-TISU#%b{!ZvE)@LVKL?;#4Mc%WoO%KPvVhaY9*0Gil^^*TuS<9`He#C7YfneCq#dpkW1;E zJ_}F=6FLz6{P6dAL_j(73Gvzd|LncVlIuv4Ecz=UZ*}{UF6jWsh{zvEk=&Kk`fX0| z%$YpZ-S_n^kYE2IIASnW^BG_S7pJ>2oN)LAcQ-RNRV((zihn)1GrixL-t#lP*FHJ4 z5eSzr<=Tk3DKas=*Y%8;-n|=>kLkUxXTtRE)sV0((BEwiE3S$)<37DS)cL3~Au*;I z@Lm=+^~7EX?G4fKcCU2`Q}7n4G8IZpGtAn|Z7P19>oQ_}>q;zjDlKq#V=75TDSMLq06g zK|G|vYDbV{a}^I+MLUaotsk`2-)Zbp+Y~p3&r>VOtbme?80|MZC^*a<$ug-`3*+tA zRg^6e#p5Iuf=c0dlF6!}y|=W`Vi@SQuZl(p`A< zF1-3qg;(nUq{0-+tG*F};?k>iJ$LC<&lXM~y?+`4RJpY{P>l5I@2e~pZM0MG*F&9M z5rrnF8iCz%LV(H0VinpR<;iod{o?{o!nh|LvNMfD3Hi|hC@~<7IfGAPfi<7srRK|K zYFLd61)?{+W)AojJ9G3(@XaNIXnd2v7ass%X#@aEl@`EKi2#-+1%RbV3SepO02aYJ z-+w>=OH~HIQV{@4GY)OT_>z%}f9+4TqRiaR3&N+CMOWrBMMal@4I3bO1|p2e6z6y-@%x zRVaWZgIG`Go%zqX(tzaD>xpqa5&~;RLw{`~2J7&Bx}(jr>=WR%ixB3#!ki~=ba{W> zf@n_>(vt~9dr~=0RaR=9QQC;a)WJ}M=R~Iq$2wx%5_<03Kk($@45|@9LfW~7Kv4^q zFz=5N~$!f)uK{Q8N*ZlDS!Uz<|dYSbxu# zA>j$}WnMvxuXpjSV1E6(W>hpV<|ag=(tv5^BpNmQ5Y}qq5LOP+sCaYyEuSu*Y82Fn zGDrd{y~*yY8bx%W%t+p8ex|yt3bh>TO1+xy5z0~WLJ+2WN|XNEElNiz5>bY$K9w^0 z!=n7et}w=np{RN9T&gMw(9Pv}ysa zMvR|ngqStMTr<@BqPYH3s0HLTS#{eKahEFos+PK)8sc_}quzMVib^B$a29Oz0T!EO z(r|do^)U2a%9uo!sT{aVN?50?lTW4kWQiH-to6{bfe}rp81fnB{OY3(I0#4DA+@Dc@K@{Z0SrzPLxj*sb|nb z#$k7_a^zrdVz>9r>;Aq;%_|0sErEPYWD-kmbd4h+m*&LNiV2lXv45R^xglmTYK%cD zGQuJjV3f_$09YVkj_!fm%$H4wpk`)r6uGbojRYm1IPZR{?)@kU(*2Lx2~Rj$ji?0>^g77Unl*Zlfk0s&sA ztnkb6m#NWN3oKxP22GEWex4GgwLrP_s6TcNC?}Wj#ywaup;7Xf~nLiWYvu46Fmpw~n0){z<#PL~r>mq_&rSohMqZd4g z$RIgRr`s5AN{k^~*a|oV10obyNf?qsrj;y)uSjKw&&-GHn;4Y-?v;UFC=yow=WQS? zW^)Vx3_1{e ztR}gpSbws9ESo=2XvGzw&nX!#STvr8JcoIL+zUbu=SKAwa->%_$C18Ca-?qxpXn|x zhVK1ZJlm_eob9e|hU>c;bgnnK&-F-`13%v6f0R#eckM=fx$&8xQfk5lg-q66puX=Os^Z zAGT7Ef^*sF9jambyIF1XXnOu@B7_*AbvH5#d=gE$;9T@y^U6hU!O*p*p(8!nPK;kO zp2A1GH-DGVOeI2SKs!w&4cB#J{wmQM>mwcqwC1jwa>0s&3FiigWk{g|btzJ>#9-?^x9&=FzWP zGLHb_f)r&*Q=PAo5yRqAz}RrQGI8OX(0^3Rza~rqGT^VWtIcJuewOIXZYer#MKv4) z{(cB2R#9T(NlmF0Fr`3j4@rZ$Xjy$P^F4uC0;;-Z$Mkj`=IUE1BYq{Ch~g4Mvt-rY zM3^~d<$pn62HjD=6lkQCjU4?t|G9(_rpQXii9{zf6iF*xRL)*X8r`cLN^EsXb$>ZA zt`#0mYO)woRf2euJ7s}`Db#y@$);oAb(tc@YQ`MMxceS;B|Dt3t4t?s zysz?CwgZ;n{2Y9j#TXs56EcZUlYdk>$-(>O77tz(=;rC`EJ}qih41qs&>fK}wwdW6 z9Ov_#!C7bLebNP|`d8~5XWjOSEet*COhRI<+G5v>MdH52!j1 zd)?nRaX^8i1^jpi!FweCEKlGj0Vk~3m#bYDzy|wsGB#MTFBbJg!zLC9+J7J@n+$$K ze>azkrVZD$0L~^s-{ilVQx((0%EB%1ueSis`!Z*6-sjnXxi+%`bG^(6a5sNo*<+3y z>?Xwxc9Z=IE0I%)_%ob$n&C>D!U-ePG|AQW=j~r@F>@0^qN(YZ(_X12@U47**SNB8 z?+RD;wag_Lq%?~Kr%U|`Tz~3Uxx$8iM#>5qDRO@r&>szb@%a|;N}D<3m0oTH3v|0U zo2MN2%9j-P%9J>p@x`(eMlmqRXZ&SE(q1(Ct&F{F%C${$;rwya7tY>5@*tjxR1lfm z2etrNtyi24qq$_%nKQVoo%?5T?=0+_g*~%VU)u_1y?=REh^V3fX@8LXlkvvp+eRz5 z(B45?6M?p#b6nScrMRyBDt23ALOUjorZwa3Es47j*Lc6h#ogj`Wg3%^!F1YN-2~~X z-%8$9SM093g7>)w(%R{+o+7R|!#YUHNC#c}_vKwe?h4-a8k7!K%^@8wGG*@$UCakv zOfM7=4PE?mzR*P%zJFkTVWf9W#u|JBm(;gm+%1m0|9yqv{jp%?IL$v31hX@e1kVnr z=}7?Wa@}>fHS1KsGrfBMm`pe_0PN{49ZAR1aZ*#5FwdDU`@JizhaKQ>PsTBCrBWC} zUbt|Rd@C}Z$kWXu0MH9gf&(&yR(Z+*JnpcKd5pA#U|Ub&EPtbaZ(k-8+u-IIwa!dd z^8jZ+n7_;ml{uAt=H19?3^acXa&Z&Q7lNDENULoQBW;tyNV^Gi?u@K9X0rt;#ALD7 z&M(<_Q73bTA^({mo^TtMTOJ(pZCG+Mg~B{%g^KjxEY2tr!yqykV$#wZ@&Xh7yWNn# zm5|5DgJ}=ER;L5Pe4T$tS||cr9&v3?3%mJJ#=^zkf(2sW#DMnh*4DTq#|L&UL6%cX zca@FzBN;?!nNmI=g7d>8f}%)1~4sbCBgu%w<+2 zItM4FWx}T7Ecb7S$igQabKEpvG8)r`qlf+b?Cd?&0!w1&rO1>n5|v9UkcVQ%mc)xK z4i#GxCAL%B%Bh6w(3tTkI}Ne(`1(ECHzx}(Yjp4y4e@&@F>ffb&SO%3jy5S@{+*td z`E6f#{kF!2noxg$=$My-U!HE2!$0RTIy-mi^{^*PTtp4d>!oo$=-IMckR!DCI%dmL zgz@k{b}6~0&$BwI0s7@e4AB0VAtMbIbAsum_*C!HnaV)z8K+Xmc(H?F+O^39F|J-= z@P*|$l@QwM_22XOoO$~?mrvu8w+~n>xI`+J>v7>V9wC319-gb@w_Rc&VkIxgA^N1#p#Pt=qu%*|pTIJ2#u{>q~@6VyZg!GbEuP5ch zHY%E6UMq2rCYXVve;5Y{h$T2>0u5?)g0k`Kp<%P+h(^Q(Y}n}W=m`#cm+Mzp&Fm)D zyKg1UAOC-!sjgy1bivEs`XrG6@2np2o!&4t_uL@4GceMqw|iTrKh`7SXz>i`^k#pT z>W^C-(4v{qXwd#D(+@w<6$2hxO)$FyGZn6*)MsHkl9AGdq|0QWt-~jiKY?wzuN1@b>K3q<)>h%Y@}2jIc!o zP#Fhj0ut<@oLvaXhFYh1>tCfX%`4fHumYZh74Rg;e{VoNjr>}1v~*6DpOfXR8eL_{ zS(OP7v=Fn%W*BLw^zaCzq zKw$?*$b+fe71|~OhV^q@A>bc^{k_%S#B2ksF9#$};(%qK(+L4)F>km#P38qO>(8YE z=~v+Var9m|+dv~0qtY(>>76Q|l~;duFqp>;M-2dtz;LyA3ePXXYeNMd{XJnKbRhhI zC*LN^>UB@*{cEy>Xm7{^OZiTcMedqJUd#cL5?L>9>X)4X0en_w!nBP9KCoZU9(cgCC=STT| zho7`2-aR9sO-7?X80&aEi|caVsKR7S<3?c7p*?6=eE*+Hhh3CY9A#e_O7D@LJ>y_O2rqg)2WCrdVh25t1LZD>k z@~nT`#uMCNe?kcFVi|Xh;w^JI#y3S9%G_mByKO2wn2qCLek$2ci&`1#zIn`oulW^~ zX>`Wd{JJT4O=7t|S2cfh0J)h;#eoe?x#s?)lc6+WI+U^bBy{Xd zBmj%7Sta=xV-iNtlepWoVBsM1`!ip$ZU_B8~ z>5)|d-I@_Y#|nSTjb9`7GhkC_*$uu$1?pH_7m!n? z#@-^?-Tw?hZZfAcuU&d1w=Fpb}B1#F_G7ztY zfwU8&@IKlIKtaC1&)JDDnW}{d;I}|9ny`-r zK=HX`OrjVkri##GZV4W{-{*qE7m*a%+<>G=FWA}ave1aLvKMQ6bWW#um*Qi**cQu-vBzTug~ zDp~&IX1hlQb~KfI7)d?N2cJ4b2tUg}e)oSl5S_aavM+^uXF#EX;1BKD+khcq8qHS7W!DZ=t?GMhL>dhRv_NBXDa*}3OTyP@TY%wi!c7lL!g z)^JQV?lHhPw1{P}u9Ss~z`2J(Dw2KM8S6}$x569lS+{!D?RD5-o~l1bx&qEc)^2~= zEZbAQFitd1Z1o-oesXJ<5Sn{~a&-$i#M))R+!en1bB6k;H$$+zM6qrx{aq>y~e0vqXb8vGme!(coVtgTYa_ zTal#dc&lSPS0TaSA(mDkrE&`r8%!~6(o#$Zg>D0Ix2fMcjT!yz9R!UN<8ld!)%7DQ z5k!_zwxOg|iE+4JCnO1i_|Kl{vr!er4XcT!&#FGa|KczkGCF0(EfDNv0t#TFroEy5EM zdbA*GAw`^QlY++f(Yi^kb$;rS*94W01QhdbEOL{ zUiy|L-Q4_H=t4i=23=@)EP-e2_@k;e6Vin?>Kbbx>1^e+97-9H+^YaDGI6V4#K!R+~m>`D1!I)DVK9ND8prIh-_J zt%tcC`uDBOyn&d%M^N9o&DYn$>4d(KNJr0Ws)lM&)Ok?oGP!@(d64MZ;uN~uZFJgb z0TNxGaby^UActI?$04_g&TZX1)SFBmxpBDO6~ybA%j)Toa{%wkp1It)tHfuPkO0@^ z4byn${__SsbI{in0IrSBRgLh>d4g)0ht5AY^c9U zTlXqyKBrssDlvako}BhXMAW``EzNiGWd?-djW=O0QAZkY*35%q2kFs0rsk zrOQkoI;6-CE!@;{zE9|svzV#C(j2BG=SQ$;qzrTc zl4L5~fcQ2wGeRm31Q$jHCu#$t)vv2v;BTaqv-Lv{+TLw5$>!Z>GX|GYu~y z75b&`G`JbP%$_`ac4>GRG|B}>X73~#Xx@&R+ADvDFbgQA46{VzOgQ1{(fKr~L(Ro+ z=ApnBoF1v)aI6H%RNJp6A@|F+FK#y#B?wHW1Cj6P`N#0$Ag!Pp$10j8Qt%`%f zpP7FiJ0`rS^L|d42joV92XlFd(P-Kb$~-RT+-c|Zjta=i*2*dU3Y#~m@HmW!3qW$` zy#1XLl)LZ>e=jwLRvtkxw5nT7tnxI57K|VmTZJ-T*LC_f2jUh^&*Dex;zHa)`B@ZJ zmuVdr6&p*+JyTH{5?}ffH`){U*gPB=HdKF@JJz-hHu@)aSUC7qh&QGR_r`3>nEE;W zT2nhRREi%T`qo)d{8SptE!m+gC9@53Xsq&JGJeIX$Nze$3qmry7=I}BC--Qigk&rP zkpM%=C^REwK2id3M0q&I#GZ~{?*NJs$t>hR3F_kM$P2~5B%tmzp#+lQpYODtTfTpj zwwzjZIT<;XsddrfTc~Q{4%zg7mxH&2(55nR1f4e3kHeL*t@b@x)8?~~VKItV4O82? z2w4!1oWD1&I}cNPH-Xg$T{xC28!|tgB`iSJk(8!a(;OgIGCR-$3@d@Oaqpak{5+7* z2X4w*Q2zUzp)Mi$Y5`D}(7d#7ljeUuIe@IPFwZ2)5mG!k753ybah{wnC3~05lT$f* zaz2*>Z@{D{pEM~qFk?aJko8PX3(Q)3DVpD9LrBd(70a3OP3sEsfh7U;{EBAOIe>aL z1yDV*Sf+;@&WLLVrzNFC^+>We0tXLA_W)$i6sVrWQaG+}`k+3qNOTVq#SDLx#R1$s zTKZ95bPw}+g~{T`zOKGn>C`?i-MfX%eh9&$J}*95tu|NKHP%@-ll#1us(TLq05E8f zEHRSK#epb@`C_5750~+EL05``h-&>)?vO&aRD5*PsE+OG<^!E`pHB*Pqp7bU*V1y2=+f~BRlj$q^g6xX^sv{%yG_gkTH?hclv1MMtQ$GtlJp^g)WnHc`% z04?&xK((RCgN|^euXCg`)+me9GN*7r%d|@6euT2_@=Iy>dLo#4KEF${*UcPBd8uta z2gz`aZI!T}EHT4Mrd)rb-_mfjE|bvWI@pJgdF;b@{KT(^I)4Tc%jq#R^(Kj8QP*Z0 zn~5E(jhl-yl0qz-M$w4o@Cb8|3Ps`n#ygp-e+H4BwPPHTXtF9IIar)K*IbmiY@)P^ zNk89X29Y`utv;O*ib_8wGsU?fyi=eSy~U?3-vWwDv&=XM$sT_Q&r+Co#sN`@v7XQy z0SA;Pwwg;B#|>HJ)VnW}Dak~r=JHb> z%8hLT8I)f|I5)~#%(0vs{Z0>Cz_~TZD2xYhGR}=?>p9okr?I5J-!17xmUMb94r$n0 z2c}Uyh@2n*`z?RJ)o01NQF=jdb##)nfwW+;Sb1h6bq8}|=o8)SJrf+X`l*fe-sBbH z61p{@o)Qlx-(Or^eN+e4^zdxRSGC`OnD347PZIjKE6k!&Do861byf+#k z)H0Va%XA@@iOrdx?g>Mj5=81ehwhQ(5MxUW@XCjn?L3B=3P@xzZ4Xz$8ou3DIj8ES$B8+b|tYf zrCFUM^tVaEmHcT_Aoe$k&+QzrONJ$jR8{1=pe3SZak~meLp74?xPJilq|Vb^}!r6`)D!a(pIzk zz88uEawMX~GF~+!m8pHQwG=*KK=8Ai5z&req|+KC6JSIkH&Gjf50wb6T{x$|&Q%q? zQW<15&2&Fr)!$xdc16~9Hhl>Zu#wTcpB z*&Ixkm+SWBsB5VEgJ4y*Gz+A~+ZlHl8r?++h1EhR%H-q8?R{n+!e_>yLIE^qt;0UX z)BgL<220PSS~!Tf+=?f+70+TTp4)#m2@4wD;I(6B5}r4uf~17(G1Qg`7}=e2YZ!2f zp|TJ%Duj_;N%V=#=E2A>hAKmdun>lOKws z$~Iu^q6dX%Ag_mYy^hBO1%KPd%fC5fp+%6jc)>1R;EZ`ff-5hs!Mz{Q*omSu7a3>0 zO(T%u&(oO^Kag5j*A@$QqcLK4|6per2k@#I;^p3^uN=NQc)NVFux$cp=b5r)`|B*3bmlZbb z7PZ^nP}B}VghMDwlTicZ&!1({Jcasa`lz0F{%2U;#?x{PnVW&LbP zP=o(3Km%1)a* zIODC4>k*i-eA}@RKK4{W8Q>sjn8_$_a%gFNCWC#nklo(Xb&Qw_tpJ-kcXYH^kDBb7BS&lZdP- zzx*e|vWHX}dGVOL`G|i>Xq>}OU`^Li%M&qK#e4bvM652hCA0?egfCn~#_|MYR#~O6gM%F}?=x~1k(#xt>zOIBJ4Ah|^iiMcw@(k-htCmV zpVvxW{{2wD{xj9fzXSM8UWaNN0vP_iOU=+LxKJ643&OS6MwNdh(_I@k6ZIGjkP;ob z_>OLON;S@xXw>D>%_?1ARx;qr_4h5@2 zWN1XPdaB_VJ?i`>h{<)ha`8tje6G_*m3~YYRq759|%JR zbA06JpgTmJIzE5-6(tZ_LFODI7WWB+Rukrq@pjHAZ;THZpN+vp277eU1P_4dN-yUM zE@oJ4@z-hL_jCHF_mav9Ag4lV@ccuvXktLYFc!K}GRVZ?(7qnam$Vb5=eI&W;{=-Y zj-cvgP=-Tz)sQp!Ow~akf&@XmAP=nzM|qR?kjZP|-Hk|bHoDU|^!7~fEwqzIZr5WOCKP~n{8#18xuL%8oTwCyio(=ETw7%Bf z;7I0wf6w#a<6;U~81&rV3qg~6JEA)Qc%wdg{^CgsT2CQhM*Q6^^nK2xzV! zGJb!DQD5T3^QBR5Aum(Y+40E47(O#@nE_fg{r#6k5*&E(Ssn-Ts__}=?aqs42EJNL zHW;NFaAjhyuLUxp`OczFI-c_5`coGueM7@KdwpJU<3 z*pmx?aNcZX2U)w|-niqlYt%4<%g>FfP@{kNi;ePT@?uyu8w-6PwVCKn;^e+wE0aW@ zhh_!cmn#6=M%n;YW|qcvHLMwwHV8N}uIDItn}M%0gpbH5tUIjUiZ#iVu;@!E*z-#A z^wM3K9A6EPKB&n#VaXwLev+VgSF`c?LS!aI-2#NKWedIv!1vY)Ja%e-uSL6-KM8*h zKmv!7?bLBl;c~#*6%HH#@3s;;&`z^Yg}T=8W;V#D|Ac$Jmb>QQ_pS3FY&R(owx7x& zY%7GYtsD@xZoDh8NZW(0fJA8m`Vw*E-l1|e;m z>5;b88EM;0hqSFGN7{b7b);>@k+y%8C(_oR*CjpT)|21G8F^bV|> z)}QC4JmS`q8yQWX4N%1CG<2chiILV$T?_ZPf zgMB0^fs;=NIJt(v$u$E`HWWD7K;YyP1x`Le;ADk>lNAI`HV8P`FyQ1915STd6gXK? z;N+SCC)bLd`nkYDiNZ}*3^z$Blf4j^G@1U&Hn2*H)NBw^GZfpo)aeJ#?TX{obwR2YL}osxk@6%N;FcpcEzKBTx@4vM^Et;tKcADRyAph- zGL}9*T`G!OQ5FIbNSnyTm;`@Xx=8{p-4w!>%044+d}%}RrBz;h>43G_oPh^^7}Ex0 zOsmouQ{;&ZM3}BA!n7%mFzqixqU5TfOASVsF3O@yv+zSZpiF1L75(iUAWaqYVK~~f zaX_0^)1XZ`!Y~|hT4{*WW*&%B&I%BTHmw}crpz@up!>Bk(zOZ)&XRpFQf5w*R&K_So<;-U!JuXI~WIP zOS4ci;R89CkDvz|f*yaY-0_Gmh(m@|gdJ$4(Yg)^Y+_dIV-X)`QuH42z*uLuSP1Ac z3?UeY$mE2bNl8nK_%@*{*P`eVi5(HYwM8MXkaYfDZf0)gH;m5sDsb5 z34>LBM?TMgJT%1F`QJa7tF!a0i$q=Dqxa@uIqK9!xOmRBdO3e;h7U2HOC$SROd~m4 z{^8_o;Wv^dt1g%17Pw4A%<;%t-8i;FZtkr)ybgkAOc&*lKtyaNn^)c~U{b$4l?4M~aV7-`Kv)nBXhu>gL?+#_J zb%>=$lz#K(un~%ne?0zTxUV1UYmHp`R&RZ*_gWnOMeUcb!!ncG;y;}}BYxfaeahS7 z*hSb|J7Q2_u!5x8N9$<(ron0w-ZWZM;+uvmE8@P_B)os`E#=#&G!~aTbJ88n2}bkZ zId-(y!}ou?ZfVDlxs&ep`iG)>L;s1w=j+Qv=j)-<4+<)VO8%^)SnZozv; z;lMK6J2Bn&Jo)rP{wsW+cF!pTq+-ij{JW{IPmq@XEYxxMWMc()MEytVZbgPts-2)uJ4Y$&W&rL_qn>taG$#w#L8u` zWRe3p(v%ai;>Qsn_|RKOIhzxz5~Y1)C2n2gaFe@ZR3^fxe5j+Z%8<|&>&JL~{NaDu z2m5fzTThbSHkSMt=NIk9IE!=+j$VJo!*&E`&1n^ucX(_jJ|GVV<_%5K`B=)Q{s zxomXT1H1xvx;oFDu0}VoR6Ctq;|ILc`EGT+ zfg^Xidd_#cO1{&rXMU%vPu>?-Hn4>@D(ocDq<%BILC zJ9iW$l$~>0P)1qOe6l`TIVGaYHw5T)LcF((vWO7iWJEO5hlkS6cuv`zGRnk{6Q!N; zoU(EmWsw2fV-rCh%CV~;9SMYbuhR(mV*f7^@>5~MXd#+iATe^R|drrA#zESdWxn{zhJ(>b6&I$X!-7^G~ zY_oA{!2WR07-Xcq{c-Y3q+UzH3*@^=H$%Aqr+JpP{rw>gZCVW0Nq|X8hdUGr)C#*u zxsxW1+6id21#Pw;GR=RisppesRs^5*m;su+jf5tf)UKaXJZ6+uHcCcIo@RD_adev5 zup)Y-Y;(A+zuXRy-f^ttz#GU)+vnP#w?09wy%^HkgMd~(?T77b#2>b^QGeLxmw#A4 zWl8$oM()N*_u@DA-`FcJIOS9C#m5)NZp8gsI8I`lVHf^%Jl(q)AZME!A~6qh?@q{IzSCaTI=pLI zi8b&eQc>&hE>AJH2wA;IH@xH6Q-Gf6%0J<%)S7EH8?}GjYE$d&Hg#K0P;WbDMLppT z5)Y@@#=&V!B0SB&>Art`w(Z_S_RHP0hoVwN!sBq>R*yS?M)a>ACU(5>?3cS-;J&Q~ znnUcZ*xYtjgj43^Hz%~?7zL4ny=Jz|DN|6NjY#NbUZ&tnp+XN_9Z`(ib# zCl0*+Si+|lkd7eEl~XnX9Fd@-H~tR4iM_-BN_2@IZOGRL4}a|Zpnv3@et-PIe9j1u z>WUixFY`A5hJ!V#YPcw*{kOL?rVcHLkngtB&o+M|#877t3y%WVnW-f;Vx?BgK>3*D zXLSB|Xysf#DoY@i%Y0PzyU_W_q*s~&jQCgh0g(}mIQ%V`2QkaE)wx=JhIhJz*2<`x zkk4DLtG+xDtbp=3oob8Fdkgw*#h%*;S(Q~WSryDA9s+><7Lj`Q+qR5qYbLUM&*lfn z`38TdYS#I}NDkcYG0gZuVlT@eb(~GXW?Ixpn@{j>I}e(CzlH?nVLmo_tdmWob+XA~ zo(!>=R|fQYY^IEXo1vzP5rLsb28NnQV5p~M;qPFNdBFtc%a5BgUu3*!XRRVL1_4de znKJocuHTE@@K@l5zk)aXm3YT(#J|5iB$R*DmnVYngpU&_|90+F9v4WP?`B!*y1yXz zBNKs>{*u3Ur-c@6{``I<#g+ApL!np_hp-vUNfp zu9eh0Fm?_X+*OVXZVc0i`Nw=nW&>}vWq3^-FPecOjKQ_@J8YJ8(w{eThfT)In(Tkx znt_$&M+p~v2rG+lOBF6FEgbN=jnlY(oKz%J2sViIAV0tEg8x>g_numtLcgupYb*8H z&c)B0!%h9=R^8N}3%IGLR7u^=S0?p?Ie^zW?#G)1_v1~T3$mP#Km7agE|J6qe7=Af z39)V@#CjnYaDPa$UYx?Qg1+vN(~E?43$ ztC8w4qW{;${rly1+`n#KUU-=czWA&Psl)rZf89BJHt)*)dv{poa9BPj&*C#N`M~C% zAIV*L%ko?2XW5gm;&Z!T)i=f4l3p=v^elT4R(x(3ta=`7Gt&J&k?>hAw|jqvgiY?| zZ65Dl_w-)(4(OgbUL|e*Ga)$o9+8`VXE^dYi%vnDE_BbUbhYl^%5?WH@DiM^_OAPSLnBh6?#{D6xANx;-!Xm-zfbzZ~zP) z0o+cIT-9CFaGKntF25F9Y9D_>>(V7SZD_}h(sKj4ZfM_e8^!#!?(%|@<0kPLBU_t4 zCfzT1{Z!Dj9J~4`6}>$LimF4AIPUfF)0B$dp1Kre5~(ePR*(l+*s{(8EEJswSbXXc z?Z)Q;UdvBiiZaR5ZtwcE(LjSa3DkPS6Ur&n3O@8$I-WQm`eYil#C3oE_Jg+no8955 zV*H(D-HdT^xu$tn$!R47bKP8v=Vd6pcQ4eRl7IAj2_Ua0|EM@RNRM_8M!%gN#)>Sz zdI|b-D01b}c>A#t2k}@kJyh5+7*5@Kf*zl}Cn@y)WlgZ=)K_~%` zokXqLG~4;eRhNOuKIMNvWo)8R86O-ZI{{pyPu)C@2=>Yc;W_cy5^#-*kx^HSjJhg8 zMm>(_tp_7D46m*Gquh&Dh|d;)YnX~qXq$>qXdeo*+fj?-)#&*DW(NAs0N?m}d+?2S zi}{`ptV51r4fk5%SA+eQ)H4mN;~nsvi^qTitYaJ}7p#Ay41@cZVnOp237S{G zRW*)gJ_)5D8>189mFm1sf>b9-<>-aZEAiRxxP#iQ%m{QDhc&MF=#JPr3)~ziA z%!EZL!8GDKK)iqCg?uy)!=IjV8IB&N#{&65&LKyKP%0UUoJuOGa*;rPr2~SUSnSyD zA7zQ$YzYFlo7lkZuT+s+!36KbV~>CC{%GJWIzM#!QFL_I9AYkq|2y!f9sYCK`9aqy za{2x72RG@t@Ow-7$ne)&hzyTMY#hHFFKY3QJXqSX?ZS}2j}>U2>T#Su6tB8(k}?OlVx9l`fI@+b)UlT zALo|%+$Mj9&#=3}qkJ=X{4L^U@OdJIxKqDDJRybZ=t&hRc-L%F9H+<4dyh7iy?iw6 zb8UsmNKN*+@E4DMPL`mZ?)UUNT@|&}Ri1lYKaPI>W`?@TmuOle?0dry|Allve*ars z;?I72VDGuo`{1W(_dzj6*qK+fAENNkWsbqV7_NUn`DD|7J?p5iXYKp-ti`XV62G2W z{(7o>U(Y)5>sf!`uV+oZo{D@uHT!z19A8hB{_CluzMk6u>}iCrX8@A(|9X;~J5AuZ z(@gf|Y$iW<8u;aiSem#mCs+1c?TH{p7w?4_kv!btk6q4_{Z^+?xNqcwIyCR?DZSS8 z<0^k@uUh%;RkgTRRpMUdaZgULSJi=gRsDhYs+#Oo71^r-@5r;?t7^PgRkPl!YP44c z=jcAASDt)qqV}qd|Glaf_o_O~kD1JtuLJK?^#{IF)nuQlQ~5S?#!S*V3Kuy8Cn>H? zp_W_*DX^%3iE)mYoEMfEhe(WflGpOes~LX?+E|3;B2T5|bc{ zWVzE+QT__QLu@^|kMwH+b=k3_+NSffOxrDLxdrXE5mI4Ew#rU`CrHNU`-ErC72g^` zSdbe6977gIQ4WvZ5~wj?qqJ>_N!U-*k}G64CJR4pTWq$B`^TARQa$F+wSP)4N!5S7 zG zK4!ee9vh9l1a6z3l6RL{)z}cs~rD|s?BvD8?byA3q(U?!C1H~y36;v9`eddC>!?1~HN2T_BkrcZ z-mCvSKzJ3n+yVC{a<`usqD+6tsd!Yj)A9cwq#C8Wc#W0qme55m*HvGhAY<_NR2l$> z5wM{D)=dTA)ORcP+~$g%M9L^yJddaV)Pt5*n=@h(rR-Z*EO~*+*n}3g!B&D;NGEU;%%n9Nobv=_fiC05H<0Kgvh^lCd^R0h||79QS!}>p_ zX6c}&C?@n6E37nQvvj0$v0IKMTH33N`!NwuNGdZ`uWSlabu)b!r3|fzgXyZWkpL}< z2AQ#dhkv(3QJC)X%|$irezC9Rc*oIndlH$}-%OPXu+i6IH0ETzRPAr_@ma*=hXUqPF4RgH6IQ?rz+QTsus_w%IBO~Cpo9qDbK0( z{hSicsZsO6g;P%=6p`o=$xuNj2 zr`Dn8)N=-=wmSNps?X=t>OZDtp7KAZ9M7on%jGcNm3b5?-AHek5h+AXz))m(($Q;1Ay}JcB&^2 zG%-nN@KHXLQ)`|PoDa&KZ-HnyBC69R&14{;#piX7_{=8KvDtn-{v|C;KI3XnAQKcB)`+@nsjcDaLyI(|R@bsHyAE061;5`${VM~)vUF3W%P zV6liSmXJNH6=>gQB*>2vm&K~ENJbWk$R7G{<=)4TZ8x^}p zTQYO8d$uHyjQ#mJhl#g|HSsnfHr{_iR*(&GPsZ7L4gtG%DK^ct(GE&59`W{{THPcz z;NR2RfZj5()7yfc5xjOux?)^Bb$?B!V9P{ju~O~fkp|Qz%HAG_PfJnwc^e91naqqI z`JAm?ntuU`^!qt}o8BKp3Ya>jY z>GQPe!39K)<29sQ&8U}IstDhDhTZQujGQ_SZ)kjS>{14@9JwHdv=755RS|Td9DNF- zeQ>Rdn=#w^y_zxkLRkTn4>GTV9Gl7jBK+&N;yI_z5Rv6|D0}-ugA%lVl~6$+DfV+HHWPGKJc$ zcQebY-F~zX%kMUi^|6T!v3TIWa|BtU?2w7`d~sAH);5V_rORfY{P+lAmc(|-1ZltD zZ!64l>a}KBZu1o0{Q`f~ba%%&yt-yk zwE1ltM^0zn$Z3j`)A*UGa$fBi$HPRyjyKx(jLy{;*rF1x8FbpzHPUsyxuyUH7 z*}Zlry^;Sn-%(aKOTiOP5bZ|}od8U=#2_obkjf7A?EJCjP3p-WPx-G1^D#1@avVXN zP3)IAgQGJ+F__~LV!?lzLWw*7S!jZ+JUtdnj(a{23K)3@hK^H$;zilBi2hY%BE0kE zyxBcn7(x%fHK=MM*FsbDoKpH+;$XjuOhb7JQkOuX98@%5kS#h*#ahlYm?@WXn@m@{ zc7?>My5cpEv&)xBxlJNyCxe2mmzbRu`);#gFY*U#^UF*8EY*Micwr4$$WA)Mpq*}w~PRcZ5lB;D>pqG48xoBx-1SOEOm7P zBt}S9Yhf%#*9SE*7{j+Ip=gXci%UC-ZvE)e-c07xmN632-(^YmI)yl3VPChAa&(Q5 zqdW3>n=$mnh|qr%BSTM2EcC?BP3_IV8}))|eBs-7?+a(J5M0V6>g!CRiSzT32PHet zhsyu&ckY@E@tE7G{pEi}Ov+>epcN1Rt$+Y%VkuFJkM}f&m?A2j9B!&o);3t1WeMMzuQn>r+c^a7%&e;$O zICN%12*oMJpih6x?lfA?!ga<+p{_kWe=FSl6>9!U9*YK7)}YGbcpO$)g?dUOS69XI zlZa1mnSsP^RZ50ggTaOYc_R?AkH2j*VNjDuS=ITpr5zlK8RVOrgGVtEB)fT-OwKCI zvpCM_Lf(JHgy>l#=d&4UC=z=Xjl}+E%1d|=hab#FOX*c~d&xAO#lI)^EM}KMiT5xf zml*dl7C_&6Ck&SLFitO(_Sfxz+=8{zB*2^eh-DuEyhSGlzx^x*lwQe;Tuit`shEU7 z6Q_e-+`cR9_Fc(eJIDqtN#OaTl;bxIEH4O_m;!&`w@e1NoV(MCJD>dMI9;4pnbZ}( zm4jWT@tlXayfeA0QaSCoug%$?r#bpErC6tuD)V(=7;1=GP@-oRWbBidS8{A@h$qO=81kw#l#fx zg1di@DzA^qJ0l$Xre=bEE9ax~$`mKQslw>DziuoaR#P)}RYy*p8@oVi9;m4E)ZJg@ zO;SevRSoi2RnT8mA%9gh3x8FLWWta%%IKWL{FH3p3#K!tgNg&J9g@a2K#h2|MT)= z1G%N^?!%fpXa9P5?79wx*FW?f4!u9@5BJht?-y@>>-^#HlU?r*{YMUUb{`R3E=!r$ zFWhm_?`P+wKF$fX66S!Y8`=A?HBGqh;pbGm(1)QIU#?&OEM2XIKc7~T@awmd(Vu^h zt4jE@RLNIj~*BH2`uA(ENj+i=DSw(+rZ^w-(O1_*2kjJ2eaTn?={k=58xHo?6*~ z)J9yeo0J(MuIVr?0l-a(W?Q!H(u9AVY-AyI<1yqeoA##yZHb`@c}#q({v-TW{l|O0 zRd-{Ki%YslsxFW0Mg?+Jmv;}VYEM6v z0=}@oh{JJe(P)&8eIAX{v5N61onAT`rBm~TQ99K$9;NdmJF#F~)#crzQ96GOsuV*% zb{Z6nWAmMP>wOv5mC^e`t^?iwQaV%9`(iqiJos`tHTA!s?jyVLD2=M>y}S2ib(Y>R ztbgsaP;hQsN;}rHX7@Ui``5qD)s+3i$&^(H_@3{kY`iHuiZw4_;(Bk>7e?p$(TJmj ziwDPmG-{nMRRD!RdcPb!+_1sHAdMz}seEuqNTbV(4H?khu`wEy?%^?Nb@vDvk5}&? zkt@D54LrSvquo1NKryO(aLmX9LcklnBL@`29G~v-14jG!6ENBf(S#lxNNBWAO+%x- z0QPqOh(e&fu%Oe; z&|IwAgFKe@sp)yCf@r6+jwJPkMUz`9sp=F5H|~-1S*KX7hcvr;#9EdrJ$! zX%1bghP>~57Pd+;d}k|vU@g1$o;%5l(b<1?29N*DZo`+WBQ3tXGfy6nv#0TV)-?FE zAV+zH55+k6$10Ia7dEr$HdF2a>31AtWR>1uz04dv_*9@n7PmQBM8Uy-fBux%fBt8< z|NPI7;Xm&houYSG^rLqTPT(aL{po#UQ}z}Ezk1Kml)c8Xf4y&H%HCt(PVX6*z>C}& zx}-kRx3(Vq5cBnt{^{f;{W?{$(Z}_Y4nJz@O(Om~<}n?8=89*D`SPguboAjXUMA+( z)9;SlT{_wj6t7bx06JBFvc;q@BtQFhL8wTHh07juv2lYffztUfx%?sva3k{U?LXsK*&#mZr74>U?&W zvg{q~sOR+P{wf0A|7r93?7~3%+#mEfeRl!^Zr3dIwh(;o*9Ds5eP{ zTqD$*f(sOplOi5))g&u zgVT7woOz+Mc)&J)H{t;U2q7md7D2rc3>NT;A*7p*yCMj;DZOO(+dyb~Nly8#Bl1c# z88NsD+N2mYwic-@CFA~~1=>4#_{TfGqT^kP9kkfK0aW5vONld~( z5L7X{qIIrf34IHH-=ma;qm+>95O+vy4D6>kuJQ;llT#R;Qai@I zmQxItd!&{t796Vqt5UwL0$Tx8gkF3}(2xqy-&NL(5~PJzo(?NdhLxwnN)>=pAQa%(8?n+4#1NJvk>Zi|sX6C2^8(~!=?68Vzy z-j?roZ%c7+ivzyd9kNZK)Xu1GHMXYX%UN3;pyPT7PYn}Fh;JNebYPMG_R%omMgfI~ zz`Mi1?q9?*0O|McqIeaH#J_#A+|IS zQD6?$m@zBlvOWB9H&AgjXQ)~K@5dUBkMs}+=8PU2DK>T<>H8e6P3o#Qo+D_|-_NZo zflL^g?5Pn7(hKgk^WL9x&Mhn0EhE^M;V+VZPtlFxPez<3Lp&&)d@gpii%kug895DR zOPMIC4eeq-&%kzOgmvut(h@E@O4>&j8>EF}cPZ1dtCWmmrObAaif=${H@QunjD(TX z2v#mr2RZxLMIQ4zYEwrZBU9%>9XOq>tq_+{3NnRE%+N&YmILRt9J86&+O9^UvlZ%p zYN!#*Z*uFKXg}Hz#;=JjZfcYUaehr~Wjpvsz(R7cwd>>x9ubK9KkkV8xg+lHN+e!N zBnH>Tx`?m{xbI-p(dYu~0mL03%mKg~AgloxzPnzFi(ZStb!lA`8m9EUMR*o~Wq~*r zgkeGW6@*X>_6NT_l2n&U9PzVE++jkqN z2Q+zNnVU!u&tUld@eXymCzhp6EW1l4O(&UTwA%H7;m|s5+~|Dr8KY1-1x6R)`sdI& zIvIC?s*(a#MyuUzU|{2&#;uJAYbh{M4iV+>(1@@WU)y&%FcD`PF9*iq(Hb;=T7yOV zhHUluBPI+x^EJ=I?@df0tep(I32Ud?m&Rqnql0*G}XU%AH7iIp) zqRgLTl*WJaq5pv@bDu8LS3VpSI1*(|%c0s?8_35)I}mIH9<#hF~2!Cg?f zF~bT;E(E z7)|}hh5YODM^`v^^uISaVwpT{cYwDW!23MB&2bDHdF?fqA4zMkx%_Cn_N21Q z%4!bN*NAJcIsan3_N3PInku(ctn;$=n)5G&wFk@Oak~S;9RcBgO>f^Du92pFs-iHPm$XJ*nQ#^bMcuuFmeLH{E?tC^KT6rrVhD+G{%d-e3(i-F;6elgI52Aa?|i zH@$tYlGk1%pSewM->amx*T`pX)7kebaqTtIncH;ty-Hkrjd>b`sdL4Z z>*)SYLPzxP>hdoPdCU0T4#utXJIUYKzr)MFU?YDA#+L=gkL?2dEnH6v{4HEhDfBJi zUySVq_$^!xO7tz@wvH_b_$|PBjO`jQZk=ytJZ(c8NB#vH`8)8wFz|k4H{oxgVRxjF z4TZjihTV|{_7waU8hl3@+E(yeXviIDU}wQ^p}{wxq0NQ9h3?1)!;$Vd(W!8vi(?ep z0vyB&fio6=F!Wi2QhVCsDcoU&5C|&(ZiaPUF&?Pb1h28eNQo6fN_-srs6B5#3?l=g z<`$IM)24@UCa6Z(lxsr(7->NPmWA=64O(>9u@=Bjr^i4+Jk|XjCwj+;j_vT4d5YSe z9nS9Jy`SRW;2T%l^(fI_;I_0~>x2G6YlmsESE@IEDC8}s#THC&K-k!~s&}lVK7*!H z2m3{*9l?OT>M;pV;rh4d<>}w}pnG06a?$!!)W}b*<)Ww8KR@9LPsP$xYq{v{&(-mt z)Ql%wRN;3u@>8eEq<&SW7FKdmBfsm^1XN*l9;jfvTcQ@h~@vr!j zQ>JErjpuremt2F_)f%sSvQWv&Z}3+JD!eSYU?5)_L}Xr+;&M8k*iY{aPFjMKHVB>= zC#(_!PkbYL{h$B)Q2*b5Clyw?`fW#8GYJ0MBW&NzK<|QIGICW)OF;$LsAf%c~K1RKV&0Y^=GyK`!fjyBP>;n?C+PmH~#~y*sEfvti(?BBOJcrl}vSvMvx0@@l4m%=eA z$9)gQ;1L#uW3o6Nt2v)xjTUn+hDjXV>cw@e!tT1KAxH0FOjiH4FRp3g>Q@kdgyZ`^ z2O>^G;@ih$VIZf5L4o1%0Uk@l-ZcZO0YFh<6oTV(n}Hpn;IN94`wp!Pg2hsxcYBVh z>n=wGHA3IP*ajiEEb={c?GX+{oQ(9hhslCKPKL?qS}qDs7tHt7ihWWZgk2Yv@C?5_L8aP;1Pj=)cAbk7;vMDSZ^*nMYcA8|0^BqYJtbAf<3HOx8$ z+w|=zlDkn8yn3x*bUPLn#j9VwDxSMB3rr#m1h%N)P$(5#plk3k!y#{CTV8iB668FV zqM+$`t-byA_$`pf2*Lq0r9~0Y!(bg2#2QCV0lMz_ytR-=4a=pT!Vq#78O)zWY^cVN*<-ip9+oap|wvqu6-PP6!At_$@7{e zj}m0h6B}6os<U8FMW*+WTOCKJ=27f21wDyR~Nrlm=F-vBBPQdtSr-)jEK%)^Lauabxzd zjS8+&TwqNf8(DK)BSI`LHN<1h(TXbC5rXtNj#0rdUT?en7c;^z5>ODKy(6gdh7<8&73edDA6AIyHbgvJ6Et45dF;W#i3%3fb_%Cq&_d-;u)U zYA+H;Nl)9#OH2=j$yH){xDn6@C5hN82$q^NYmK*Dg++zN zvl+|QXg5_DRT#q%rPLa2l?uCx{u3pNuMeJrY;hc4buH+BGfm~H^-0v1oVu*_v6m(= zScy;3d$~)YdP$)=ZCOqmmin#G>K%3}{26I`l;aKsRXNF)q3ulLPa`;t&s}nH(VuP% z2VntQsEy3|C;+2Gj}rnAF}9e8i$^R_q(greD0#WHmNyoU4^|aV!BRfcDjd}r6^@j}?%-x$Cw8;{C+#$CmBz5_i0jjW8W%c#}TYHpX_Ck>;X&!5iT` z%<#gT6YHTOJ^D}SC=Uta;fnAe_>TSx(XkzW#gQEfszcE0l+hexB!?4<10gsBIZ(jz zR0_3m9>mLN=0k5>J2VM`_NR=D4p}B~d4v3rJho@tyWqG6#s13z0#hRn#JsTCDA!9n!h4V6ftT}}6Jv7!F z9=z6K&7TSkqz#D^!0+mzd8otOcW)1StDU!GU34(7sG zH8pf$Y{O%ve@zuDJ$8Nk*S(k+v6Id4o#^ps$8S4+8~03@OWm#X#;JvjjrNKdo%QKf8s;1c+aZA2yrbt$a8 zRx8C)Lx8rtI3a3V7K2S7>Y8XEtSjOjK;~OnbsBshF2F&3>u2N%I}dKW<*T;~{)X>Pk>1d>EEF&72ms2!CP~;GK){A9C~2pl z2ld}YoGWj#6}tss5j3QKYzIDzlLQ09Zip&OyTv)!3Mu_%WrJ&-qV2yxf$wvfERO@r zE`3wOMt>EXK*MIxkSSy{$lxCpnF)Cc$qe!dG;9V9nL;*$ZWH)(LZ{9`u{jLPVutzW z5RAZ5)2DLuI3~USC^AKaA%Obc7O)J?9zeu&jxjqAf&|m+|KzTJ(GaEpJV`2500s8& zuc;JKf(I+XZM#fW?i6=~k9&SuB-sx2EY@}H1;42gojCDW0cvQGxEioi+baQe9uKSE zI9SAmO}6L?@pKa&Y>TjbF^<-wZsMnyoriq;$gdB5`pBOTeEHaqFD9F! zL{pUPh;uDriYX)!N9m=IJRB2;!^BdMJh_9h0T>s6F##M8K(T;99AK2vDf4&lL7WK1 zh+upO#fDH^2qZ6q#ATSYj8c|a(z-Lt5lF%v=-rMJm02QxyCciddGuhM3C5UUdQ&5gsJLL$L!V#;hI*G88y~;s$YySv|&KC}x1- zmN;gK;*~g7iQ<$vMwv1`d06Ya1MB~Mk=1`V1tYn)_xHy;vigp!PK&IT^S$>V>z)Ox zd7xSeQu6?RH4RQ{foYO~&a=;X{<4-_tz@4ox&u7>T+tiQv<8&ybDAN&Q-a+}pj!!Y zYXNQ^+#UwD<=k#Yfv~4Q3=YCj02my6!GRZ)VS%zML@_JqOdmna3N-V7aH)n=6)O8j z0JB2>vJHV83W!4ia46sn1-QZ2HlRG{UCTMhhy#p&IJk%di#Vu=vWHOS5Jf-6n>D0& zrB^g#6up?#tRcM%zoHMrfl3-ki35~0a1sY5X`rNtSUaar@_|FFWO9cF6;zymT;{3} z={n9N7Awf(0r)^jOrb^octjRLf)I!-1Vsc|76*|o)F5Hz;mz8tQD)&1FtCSpf6x6`hOi_oTn z_$U;a%N;8N+o`;jl5O;|Ba$-MFG!xD33%>k;X;QNa)wC9N)mmc~C{E0LnBLUZkv zGq92~AsSSmxZEvr+*y>LAy#FXOVi3v07s@}UROYJO>+op470CMG7cQEnr9w=)aWUg z2tZw{gpQh|y?`Rf@ayKfj8HeL6L1tK$O$Pau$w3UB#>ihT7BD9Qlg?|I@#ZlES1Wp z&r+$P(o{bCavrUYLFCoJ6kQF=jzwlr)_LG~Sgr;z*K^^ptn=4n)qSAtxwc{14Raoy zdu#UMP#C$iJM2q&fDIV_?!4`P#4;mKD$1KVKXagfc>BNOHF9K*=$D;aaEcl>;u<-I zDjRW)H0P>?H6oPV^Jzvo5a&@edvS=3TzXj@(&Y3CN7LF}4?D5U$cqK$*)-k#Y4AwX z-Jb?sXuA8;unSFRe;N)<)7hT}Yp?0$ zlmr6pUzJ55Xz#)-22^@~*Jdfg)4x35^Mras)168%yoUW|d?5$ZXy{arF6ZDEhAk){Z+nG$n-24h#xmPzPqkcw&ae#XWB+d*0H2&`1H(X4r4W1`Ajo zhAyFz@dAEf*iu#UnTF;J`W6~;ml>Ki@LOoeU1nhBz;6Nm;-2S}JkM!pG~sWdA$O#q z0foPXhTV|{#uWY*x=VFplxOwZ^BLVJ1dyJu$VlPCU7w#tW!K3Yl|Q|f8&>)82jJN&sOJWVHs}HY5zVkNx!r>Y6I zl9xAf-Mw%3ZCDtZ@-U<_P_to$m0We_Q<0wSd=28$OW5Xr)AtD)1jp+9ZIW?g}r#*2r1FJz&TmH&i4B~amI$ps^}Q$gP$ zV4Es`C5TvV0(*yo5i6WSVsU0etRlg4PDKJ`5J0EsP!p;fn05g~U&!^?0f$~C#-aZi zqn7aJT`l2u)DpTuLz(4%_KL{eaG;35C2s>L@O~grM&q&$B1CvE1SlhOWn_3i{3j!H zSs_M=eK-036m>#TSOk?g zK6DfW3PofwgyQHJQqU+2okih@qk~C7rVxhd_bM0uBZe#>k=iuk!lFvl7lbl;lE_p; zVn?oBj`+y9y7ltXMK9&+1G~|Reju_e_Oe3nrM*sz)?R@5{fgbm_E4dXWrV=4{f9F@uI6pB!ph)$)%lquyj-cCRcr*VshGG>Nx zUk0*RW@V#Hv+$8@J=a6`kSK2vr;Y?2nMR`CTNYkn9}1vH3S@910ewZD-q6Q_U=nUz zNMB>(DN!N)vFdk7^xY#0ZgfR+DuvE}!`;hJ4Ryp~=kiVi2UZ=QHd2S3V?nC=eK5l< z6lxwkITr-af^HOiKj2@sHbtK!rpQkt9!9CivDM&6&x%OFJBr6!M#*IvEgN{E!JJ@_ zeXY*L(Un5l{G)Jm(Iaf-Mdw#59915yR+f-=ff#TeA^Av7uV^Lt^EQ+$Hu{f$hgB0E z9DRNI)8_L-{prPrr;Bls1amOO@!&N6JY5R!jleykMcLdrmjbv)w1`?5=Tha*J;M3E z81K!P?<-+6O<0zZv?H*-qye^nI0e{Zn2954NAy(PBoq$m($M%9eEhrL3 z*$|nU>lOfsr);R=QgdDCOY}5|X%LK}JiH-e{?!jFuN~o27E)?yLxRXGM5pE5aMgxP_a$&4RdpIyqlrLpX{X z%9qG^VmV)8liFq(Ez6WtiOq+#St>uOtxBcZs#My!N~N9CsS=yJ&62@pX>`iP#+hc$ zfm+L%X3m99sF{t;!b}#$VlkS@BAYzKWO3w~(HL$ZzJr)7ip63ylf`^!o25}woJUE; zddje#s$0##W_iNtjTKLSo2_`-Y&8R$rNJ4WYo77B<{6*sV4G!?J_)Ug%0hdySvILm zmLyUaMNAfn-d&Lu5aldHIe}4<+gQSaNeGD26=^~!)Ese9wJ@WNu~+edakIQg9Yp^I?zOk4(REAt_0JO9-tCY>vZXUch67`&yY9KbR_Az zqi5)QkyJh$&0Z^iMAK@t+h|nNBplvoq|FY*%vDenT_aw0;6}#K3ndKSU{~2%sX2g% zc;wCUq5@W&WsZEmjSDgWkp60plW)a7Am#gEpO5zSC?Ag@Uyso~AH%*Mxn|X+yVh4Y z!>Ew!o9+>Al+T)0jy@pe`{Bz}TjKQNAs?61mr1{Cmk;QFYmpQ}F}>kg^xSSdCI;F2la8 z7|XMzjKnp15GFcYegJuyYc*Zt%ZyVFwMY*p{Z+F3F08&`Ln0>IBSMj z8Ab?pzh|^fofSRea^cdYDuStMw5eL7scPh@dcspx;i;dayF%nxi0%roV@<|9IEZx{2Zsi{T+5b!iAM$dX&TlQv#PfqnjL9bS2ji39Ys77 zY{yr;_02X!DJ9q#PSu(dg-wtx`Iy+2{3FVi{Nrv*&fb>n{HF*)etV}ZVbKCzkHc$3 zMGPe8utN$FZ*wP0M*z4nf*K=@4y?}sLB>#*0|AVo9tYOqfY4%~!C`hl*JLM4p}ehs z6|qfwSyrVT$Z2%f5SDh?EoEr*URXYKNt7Wgnbmmzar?v5&w#ik0#=yApWU;c+cQ= zE38@a?4C+L?V0LRJ5=Bqt!Yh{YPVz=4tuz^d$(bzF+}r1c4%$)@5e~#iYg$rG@~Ud zn?=v$3d&~b-KSADiy~5`XLki;vkY$AK(`95?cU8B2%jxkr_d7Y-Nk7h7ge{C&iLN# zoa<+`RDer3`}_BGs^TWx1-f^CcbcuG2(x)-YW42)iWr+B#Fjc!t9Rp9gx9#F+Ma2$ z?Wrc8X_=^BOH~7r>fmJo2FDS3l5bQPw`E6K%l3qp4l0Zev-9-|IOfIhovnN=ZS@?$ zRw)MS62!GzeywZy$=LGE1q97oO{=k`g)HT{#rH$yh;_&(^$7G+6_-7K2dnp9>WU(` zUR7Iu+PAuLr5i+{Ww-X0U3*%3Zpg@otLfA7+oLWo=?trAorF4-D-t}yTG4jC+Hftu z2DiFMW}7W1&<}y`z9AWSBm>!Exieu)5wNZ29e@m)aC975 zG$n8nqsvseG$qh~ThH+*f|X?OThFyDf|f)OT;HuMf|oi2mlAaH_A$Kv_4SjRG3dmN zIP?2d#F=-{$sKg!LP9B=A4(9D2I)2bKL?_W&lM%8sl^f^sVLRnaDd9_>`{W9y8qsV zo{|Oi^%#^BRV4$jj7}*U2xWnOHMq0Wyb&DGGCafb`P8F-f}`qip)jX96EIk*eq$)) zsDHq6;80_MLLtDu^8@0*mI}-OJ=tP_0(IGlK{a*PLE$lV8$m%XeP4i~Dq#bhoI~%P zfh>1b_pg(bL~s9kU=X0^-FA*fFn>9VRIs>0Z%D%Z?DcyIA1#Q>&(P9K0AxW5jW)4# zv1mI_44<5TS$Vm9Yni!`>^_xDJXteVGmF^9AU_%OU;^4u_ylyl*D_nEjiiu!&KRlE zX(d6-Nxv4L$dSXfz&Nl?F5;5JALo$bzRaBE&*KJl&bWb+Jns+(c+NwB_A8tK?U!F5 z;HDhTw;E9RD9&DKIYoMXafMSbwf&fmsqNi)_CBtEC;6Rv)yhzpc|*?v@o~=RE=L~& z1eci>o5QXSl3w?N9iN%m<>unOn8@Gb=&ATx=qs}GbPqT*i3AJu-vt2RoB; z3S|c|!-a}8s7Hqjy3*AU{v2Znd$@kc5z!zI4-q7>&aR&(fs^GiuLlfzj))TqxUHaf zs^f%z%HtMsS1YjI*huc(W&o7= z|3vmb!2Oqn`R~-IH}=2I4w%F>KQ$BJ_vx4bF9SeWum}SdVZS2GSA_NI?qVOh*dcQd zX6?a@J(#TrGxc~g2fo8F&z(^)Eu-KqhmX9N1_{d`VHm{hf`nO+u?pT91%>2;OpJnm zm`xBf31SvO$RJ4A0|S`@32Pu>48&}Kgej1*1l}0}aiUCWhCs{?h?xN~D$XjDO?IIrZNINPLM4s8I2UAx56Y=zRXjU_)6|RB&!Nx@v4-Tks&jX zdaK0@P?UFy&CddcnH<=zD92WE0xn8_7{)2VLb}9CZ&es-<^hmWaW2A?LKpa=BOF*JkSu}_BVpy9c{$&wD`(~8KU;-g!eG8$4 zLosuUn$Eypo@lN%fQgQSy}Z-Y9J1Ej;A5UCkd|cHg@GCgevv-}(`!IlWNb2jUsGoT zVBI=v=Ul_lYS?!G`$C)r zWW~V>X%QuItufOnY(^Z@>2newE1_{ERJ4S^meAh1lD~Sf$+|G&ekw4K7N2;2q^rJs ztX9QJJ13Uw2<6)5VQ>8$&ffa+`Jvu;Vw6`0_y6iGLh-tWMuleo_UGk)u(12g>qV?r z*eg3$(}!Cf{@NTX?-iP3Y4^=unC20FOu-w97*-KyIKnxPajOhlDB@K|{XUJ#px@mw z8IIR+sJl-Or=jz7AKG@A+T+WIg`KBeNoOx@f2(pmSWBnrULPu54?1yu=C!5kekVF* z!yaB%*jiXF-L-job3)R8S(S2j0RB_yW<^A=Bh@S+njN4(Rgzf&?b;dR!6EWb`dq;1f47a525<3=Up_omCY8-5=Q&P@;nT~RHG!Wn?-324Y@B62XgdZ53 zIZ~K^aPo*SgZ}vwiH2nIWg04npv?#x2OuO7!c0ft5M&t`zwTLoAkRW)S%Asz)+A4M zrzXi)H8|hopLA*pp~>x?cyi$Hy4d8`^cpGmruPeZM7rx9EZpIFSjpp>h$h#GBG(&j zsUXsPC~>_}K5jI)5N+9+pS`?04bD=qP}mMPI#?}ulJ06{}*<7f8WF6?-fc`u< z2xl*gJcgb9%Occ&#tQlNTI7UnAuOgwo*o49LGd_@lemNy5fY-tN!bPtp|bgcTCXv? z25y%Y`BDrV{Oh-48FB^skU_+w<8ci3zAEZlnJEpG;2sU^g#~7`NE(m9oj)+Jvaa*o zvb!YzNoOgM*YxTF7mSu{#@$BXpLDL9?IO+znuyu->9=Qp^aVci-FE%kGxlgdOFQ7< zU9zwB83khwPht1?v-JJ1rS}~ED0_6DmlQ(x9I0ZJXr>x8Q%~AVRd}YJ?^@w+|**oXh;Ke*VnSuV48R%6F z<*aU$VPdF%E@nmoWJAd^Gtw+G11T;u(&{o&%L-_v8EKdYgC>moU?zt%*A5oWV48+g zKVFD~|K15RkX$G&+%q_uJjug%nmr#cVMx8qS=e<6rer7mHI=ha)}fL`t7L5?BZ?y# zQC>+_V*0Sj&azOK0lHqYHkbk8U`EuTGC;J-;Qq{iM83@6Nfsvv&b_$S5w*(lFH*gIr}A#oWZaVQ}D zghy#F?(%Y@LE}JQHTf0d`eiW6YP;kWYO+ibE2KGJeN zDuOsI5T@~gG+r|s!NhAWX)xJEMv<~K-0Ub}3MOxXH?|Rp=QQfM+HSoTEKQd7N;8Fj zYe-&Q-CXA2RMKQaVO^8iK{mYj5lWxtIT=0}`O(lW|F+%wm^^8P;x=U(9wn9+9s{Pw zglT++-DTl_D$*K@R-;1{uTCDt`}G#Z3zpV91o0kB_3IwUyNN*F5Kw=+{P1R!qX2Rn z;9F@D14*U;MDsmTHj+*O@^{1x4U(aM(kEiWgxcn|h~#4eon&z*3wp-AV~POik9z?0 z9sr#*06P9=-ao~&bUbkqQPvuVJOVY(f$~O=4lkPPKvR8G{4PmC86RpakHx%0F6dyN zTAJ*HkL%2o9OPu`I?Dr=pDIwEXCITgcPQVymOrGu|N0iNr@-=V-|_RE;$Z)OWvaBC zyDQmWMtDB@wTyI6Q#7WJk-c4~F$|Zq&6j2~JEuJ3i!&;5paTJ(&es;F`Re)><4#r= z?49SKwbgu`^YiDOb=N}vTqa)zkgPRa(YHd1zKK}+8q9BJy}0TKsUV@3iPZ!eg-}xE5=yG`gp%G8 z!ahCJFB`v{up!4)D~OEl1MWk~P>6rujr)G|#Y>HErTWh^ z($9JkW% z%62e-vf>e#-N8oP$$wUYCadL+l%e1Q&1Ad>aruXm$HuKT1M{LI#4Nxu(A?88mbgjJ1L=-2vb?CVPZYhNJxCb zFBD@{>6EF%QB7)9ohnFGrx!$=IzUB~hI(<(pfFXFhqo|)B24wmi@1=a&9HnMmTh53 zx`iPr94zzWR`aG_(GbngGFxxCE)xL?oYJ74g|d1+Cs+&eg{xvV5ocPLol(X=~nR6W0ct zr}pf5>JHetKOP<2HQ8yeWmD<FgQUy7}B%|20IMp>Qxi=gukZ_W$l6U8u?dLF2-g$x`zY%>+j;WMxsgS{O zc~10ynJCzkG_$SYNpFpJ9IkQ9Z2KG@N;Io_>ot@hgOg^Ztvs?TL0G+$b{QOmPB{=3 z31JpYWz7>@PQYHDyZ+@+My7VFmd-bH$H^Ed8k1`aYd4icQjj?CH^)fMSEG{2T7{+si+}; znbtJfxQdEF-$5C31Z7lgW$S75gtTCRWe>^9#%c8Ai_kv^L%s;ZJ_+(g=pV%Q30ax% zk#*F6&eqK01+8l=8O5F{%k>p{R+Ub)JWws1jPFG)d$L-0~GG_d=o z)Bj9ZgxWLHv&I?0);S0Z!?uw77KUwqVMMgzq0$D0N=b&YB{9@YD1_~YM(fQ0kx=7| z@Id-pzvS{@EW(N>#Bbw?oAW|WUP|;XFL);pc)+Z}bUv5KL^oG?n3A{QDd;a7PpafI zksL)1Ak;uVI4PqAI8wkDCGg%UlzVs=rH?#ypJT1 zv(B}Q^RR65cT1t%%UUOB0drb=!w!v619C=sG59V$MmpA0F0)IWLlxm)JBl64L_qCxE5Ez>+zmie7Z8KPTU z(E#Ufq(#Kt}`G%uQ4&ZvLg@?4eLy@-s4iACxRfSFa@>Q zxQK+SOvEfW4VoY^Vt?v%*O2wpm;xG>4&vIM8b<)r^sN_4#5%Emnv}v0WF)a)C*~jc z<49s3iXiyoVdcePKS&=R=9dpMmd$=BtUMM~_7PN`D`&v6)uQ)&xW# z-y+w0mfZU8`^0pA#LIy=IUpa0C%Oi6aER;)faG9@7!@$u268tbl7&5BW(YysEMbAn z#;}9D9=c$;fVu&HTpk2{gS8+ae+PVI4~AE6Fl;aEx`Cs`mILDX!0QC8P_z;OuLtDx zKzttX0|tNJDJusowLSQ-O)KvNE!a>D*il3n-wsfeAk-h?3W7XAh$F}u^>_NY$m+5O zwVS=n^kUio6yW&7K`6kB@@^~Bz678EZikYfn)3t?^{)Dis19t`ie z!LYrs>jsXNwj6{>RMtt0c!vPz5ab&|TtkRw2yqMnej&1Qz*5_T58JfzPSAo4#ef}U zhk1}7mst!Nxl2`pxXTc4nM@tElUb^q(8+yf5kN5|bO581M^z1&NdO&H-&*M5aiY-4 zeQpNCfrj~i&k*++<~_rlXP__K_PIIRVAo^Ag`8RE3jvJNnY$@i~ z>^a-TE;7fBidSlExe8OR!jh|yKq~CG8Z$2MxS9i}D6ewWRYh?%GzZRUF~SAov{u`Q zoH&Iomow#ZmfVP(ICTxv4}dDSa@AI@*vi#^TIs$m=cw`I&A43Tt_O%SlXF&bmQl51 zIh@|P$$dW{rDuj6)^d|+UXwwU4_vC07aA6a<|g+&s+{3mV>jo_<{GQH#%Qi6XD7~G z<~>t}%_yVpwiw}paaybN8{%Jymdi{t&-N6w%eN|88k)<@HY3CLIl9d$%XhFmopW#| zU(oMkZ*1GxBpchd?Tzg`NuF36+qP}nwr$&c_xIlS-m0lHr)RoOb@g9ozSHN^&w4`P!!S{AgnA=d>6yS&=7fiP zXO`gkp^NBmV4g&Bo;l|H(yRuZL1ko=@!6Epo+0)DABap)YpgeCoZsi)%dV~X1M&E( z4_Z$yThUZ75SrgN6=X?iljrg^v1t@#(#py0fSWYX*T)hTso2-MCSE_7&%${*V8JyZ zDJ+=3U+4$i%Zxuk?{7aHe?NV~eQ|tUVgf9>;-CD3M-esZ-gR)A73FpGD92!&=ir>n z6O{j&)rdBP?`IY|#&$J+oex!6Be4>8w$7Z3RSrmjVZeN6r#ujJcD7v=q4~K$pILRa zs@y2m2d01`QlUUOqyE*zhjw(|02YWsGf`oAob@g3v$`QGIfAJsqmk_dU79v*5k(2?~sKCi5;gD4DbfcSaiuqk<_eIA zvk$U02)f*2?#ggflaJI9X%6SzX)3@YI4m3wA?TJ-aQE$O){^j3r1~Vk12T7uoV;C! ztgxS+Vq#?qvZcA>PM?qik($0>HyUnL&mJINo%;nTh+`@M7}@ZhggNSKrGoBb=r?{0 za5JU0RA9fkU6J#Vl***K@1%_=EAPak#NAKX0A3=hTqK{%zcB0IusrZHu9P08#$c}9A zV8Xfmc)*n~yv=m84!RE^w!@)lGO{7LE4F^*IysM`x+ZDQna+VHS%6jSh;$xJdA->M6E6RB^Vj1N0Zo2Hbeb}2uZ}A18!;R(^8EZ1IBDC zjdMnFmD$iLa88vRbcP14pl~(UmB{rpLLoFyJyABPL2dR$OdJ{*LBKNMknsb?vx4Sa z(K*T+=@?p3o>B0JRVlHZv6;o<1t*(lY!gGsMW;1brxuSP838S~s z%&JHdANfth|G+u529}}Hj_ewgB=CTTRbCQ*DM@M`h0Q2;sYwVGqOIk%O}{P4%Sr@X z^(kjMmeobmfMOQ-IAeV3%-Iw^y4Hw z1eC{i7bZDj-=q{UmVx@ni>&8zbA>|kOG+!rjcdA&>v12k1GBtxkOYETq>gcUYwLazqvLcdKuS@Lv6~OJ63UYtjZX_)Q94#OSL%g|ArHsX06L) z{>t3h+kUDgJsy}hfpC6=bm7<%4=O71{JP|`AZb^x?GWh zB7FGz&N>AkZY*$X#2iPYJh=Yrn+k?mlC??IWB}AWO#VFme!z9|My>t$@bueKSn%IM zDhS(J88-@lr$G^*ST7NMPo&PV&pGs&`jbMFiR!OX478U%luK*JS&h*-9b$OiyGE!$ zt1hvmW@t+L=85gua$slYpn93Oj?qYgxDJ9|;!Oe}^(X;TH8mR-Jdjt)MYu@B*}g&r z$0@TabjU*AF7pGmQA@+#E<1xn*A+yxPq7B8y|xHw9dN$1k~rLRe--B*fs)me2q~#! z700|;1OE7_=*M`yE3>`Imd0|OyLUPn47#+^mlp0n#A2^o90k7IDvOK7bs&y&lLi;g zf{1I1Epy^Wi%p>qk&El(GbPS%&}OnBciF zvOHW0*B%Q6{?C3h;nq=T4sMUXW=T*|!-n82oWR`xGTPQj2qGB>EX!R|SlxWTKM)RS z-?oQ0eIGohqrXyxQSWr}ec%i2cMwy(f16 zoB?kEhz8CqX1lxxsoVa&BQ&9f<7(zOiv1o2uLp}FWKlbmXWoT({*k3~NLogfh&kop zhSHJz{OC9%XroJO*N+;*XD#>+BcLJ|VA-*8V+rwNYIKFTEF~K8jso}+s~xYHm_shHD}d#R{| zA}~}g4#H3)st&>s=D!@s{0p?wQX!&gc2hy3&vs%zWEIO1XiL=R?t?v1a$xFWT zLZ_53p3|Uas*tJ;7+vHxD5|)l;P#kKCkV6{;1kBX=F+LK=?+Ii?cCG9U#Q^-&xG=7 z9i$Rl*-c#FI7ljeicAnvNLZCTG5H)^{mfgm)0~|{VQh@ewJ_0N#7EA##wWoVzhQ6 zpyxL%Q^q7P8;jq0i|=~i0sv2(ud*cE7^vK;$la<`FLCQPxau}2>NbjuTaJb#x^0K7 z&sI<7G zWBl)H;WqROhmqu?imxwD^F?}^kf>BqLs7K@899z$uk(#C%s~GL6pCXk>`M@|Qfwt- zU;n;sOWh0uGQ<84i^QMcUdohI$G5`kOo!mkPB##Ox9PPzoCsF9f>ys|QUk*)?jq0P zs4c^a++fRA7r0t8DP&=X6MZUG!pL?IFCl*eqEX(Lr>9~8`d%K~3Yk;TRsx%B&MR^>-A%sOtObCt$O z`n6rhR}4|v2W#u=jF45|Opx9x3FoxBCVxNqO-dV8Lx5bA*NJKk`?)Szu-gP9ht%Vp zn(0i6#~5)`BP10+8Q^&mxWbpR>bLu zf&(ZXH!_Lxqi5g@H^*wV`G>Z;u|+DLV3H6v!39sAE@1(W-d((ZK>9Ic8SZLeLDL)( z#`Y2IYYgd4gat8E8c+#9dJgW&MlT;N!~yJ_gg;7Sav^ux9p;goe-|Jztwvcc}?PR?dydH7(;Is=(s~5RB!KpdZLa;JmBIKiu$=vL3FSNVv{csv4?1?EqeLHUf&UnUWcROjuXh4wJEqeB`eGIjmhL8RKjJ=g~S+W+cyS zWM_h+1KM(h>gOZaaQnnKIDt`6wgl;X`{gU3&E`d#*^Xi3Tc2^gOF2SkqPZtU#to_G zUx4NtCKHEL!2g#!t6v}Mn>=j`ldzAkj!B!?rC>fGx(pmR5d+3(a}cEUFB+oHPjN9K zrZJyNLMJ+C8DC~jFkjPYVb~t9F_2GnD7ye<%ilh`{{gAAktJ%m#w+gbf!NyOT>c-8=K1F)0U2 zxKnSB&$KBUSV;JqQJ(}*X&$V&QeTY&g-!$+3VO{DS|dTQ?sh=F_+US_FoNQIy58VN zySF>hEevYp5gThX?4i~*Ua>;HvOzz#lCtWyhJmb!xpiBVDi*{NCLK{F<>Yc`izI`< z(fJW6#~rwHodIvUu$#QFs4HH=i@e+)SG?Z1B93@#o>R4)_jDp{c>=|U^$(YP zyVMTRpeHa65r5TZcNXe)!25K+|2${#czgTY>86VF!RmSR@{#y(&b>Re8}*+I2O*hy z-mc<*WaNn=6^9o53t=U&$HAstdY%tx*(S0fL|sMffd^9r88rV5S_2OrN5of$hI5g( zi{x9@uV6y`J@JsQ{NoQ<56dPZh(hP^Zms+y$ej!j_OQ&5 zI{d4Of)~P4{Vm_eV7ge+`6G|)xV!BjfBS!)lrf(K+QN4-CW@Y%*&FS;=9zK>73_Jy zYv3ssJfU>(q$`MvSw9gvSK~%w?!|UypFmK`uT?G8r#D7cL zbA$c2dgc3Li~MZ6qElBxZ`jlG?}&N!ux141b;vn&A-r7OZH_}$5{1uiaXB>1ZIK5( zm&<4UAJIfT1n!rICjuZlj6A@d5Fm)Wo_@^_qUl|=om#p3J%s2`aC5%RYw=IJZEq~X zffN_VDt#gfPB$ObU9st;189fq6qA+_gzw$Y+}uv;ilc6revdBETwLYLOzw3osT=#|- z-k`ntZ93BpRVy*4`c#M1Ff4z;Gk_`UgWLKU)$G0-N7!*2UwemQM2vjz?aIHzPM>iv zV`PIP?hS&9Z@YS=P9Qcd%Issqp--@cUzf@D^=RNJ8ae*)Sl7OYvEPs?^3h#>Pi!Dv0_Vb!k2iqdD+KSiB4> z2hg(7G$^;g(z*t&sjdGGs%W&e(XfP7te#g^q;myUD<`c>Pu*2KUdGZYnjL*O8>JC= zMH5X~UYh+-1LYYV-?1>6a5AQ4y#r2B&RW&&o>%e`Y!yd^DvO#CH1=>WTW z`E!l=pl5A65}re5pH-mhWMcX4RAj!|9${{tl=U#%V<~WuR@!f}n-oOXIby*lNoU23 z|AJlGnquh%7`G3Ktc@`0Ef*Et&opv-xKfI&5%~cd3=nk&Qy6tlnC?vmga~gX-Pibo zpK!nsD*@eOrz9*Jib6)RBrh36M%&Gd*X{q6R17i{O*Vr4_^|bsP#R`Z5G6fCCB`o@ z8o6t8M{Zi4%%+c~)15!By!WhN(DaB^E1NPE4NN-!do1Bmz zJ~FTbTpi6S@w+7UxW0uvR;65}q5?0RG~btn_#f{beZMu@`*pvU@9uTlK6NC#sZ(y~ zdUTB#iMYp89NV~E`a5w-$6nV}RmevQboZ4Q@2F2+jfn2RNACu?&u4(gepJgSGlAgQ ze22j#tVyz8WWFiAur$=Ru{!zouaS=fv$$7$^uN3T z;-}fd-4qI>L{o{ugN{sSZd8s&56Fx*%t%$ z^TD)c9Tj0$Ac8!HxUcF!Fu3wEa67Sf3Ebl$^7#7TCwulTb^3C&&8hTRxV|TUhbF(Y zN7@H`UmqE_qdk3Ecj2|`ZX8)$)X%K!LNWCpO@2LeCU$Ri&zk|=w;9XWJQ>ZC1?;P> zeo{{blHhB)rvbk3aPJLu6l_?qp3`rk+iJnRv`P|7V!Y7PYT=Ncy?vB;SFgn>aO6v`}+f(`Jfa@R?9Zfuy2hZ1F-r%IGWGhk^td9=y!uaAzIzKt`PG#~`YH8)5@^C^V&LPVV1#n02Je728HFU5b`}rXH#4)>O$j%U z59;jQnEn+`@|7@SRS+E-?ACrq3UDJ;|CNYL;czVr~YVu#~@3wi_ zNZ$HvbD#uXcGKK$L$exXS-ccG8U+{S>-0vY)&@+`3H^-~-s^*tnY19Z;h_<)^@O`HaQgvAl^TBQ0kUz(-PXJtO=P|S^N^4Wp2`j2L+>Nm)Nq=C4xzdS zS#Cg6^c?+=T}b+{-m^WVD-gb<7YY(s(X z_dg+j8pMTS&-^=)+tn|jv2eDKG6GvCyVo_{>vi?#q!3%H&mud3);X1=jGA}gje@24W%jr<46YFB870jQ56M|!MqC`ZAkDUA0cOj9aB>`Qkp)HP7;55xLu_1x0JOI{PdIc#5WvxCTB8+dokzD zQ!~NO@E7UbFxb=@2dQg29EWW>T{P)+KnaX-SHBrw#MAn=uAg;4#4K+dWKYYM;J5-j zoSgDR41AK=`cBBMq-rqeL|aOH_QFo~Zl@|jdhVtMT%QX`lvFtw&q zL?jOcy-CQ40kC^mO)>ymg7tx5tS5}{**sF1re7luBHRnY;_g9@W~@g3xDTOCca zrb-jktCRZ%<6I)cxcgnh>f&cx2c^+faC~%(|JfGh_ew1#=G@)-SI?}_@X>}jdPTcL z=1TpOoC|hVqXjF@oa1Zz`$h|JPH8@t?g2HNZ6L~Jn&a$D38zfYfXfQblj&W<&MwG%B?> zKz5Ie_yISmt_9W)Fzh;YXQY^^Y!F@x7<3)NQYBjy{jd!xP@6Ceeg*&$;T|q3p>8-m zOqgbp@DsLfHi8vZXyOfDaDoP}ab}v>Fz_1-V&}8myMO!zsh{|Rm(V0SQulUmn_uC9 zj#u;kB$q5%18Mu~@JwkrBESnM76KWFa50&-jEun4dHvg5??>Oxq2aXrR;n^4w3pY> zX400yvWTTpc~J{*pj#`@6K{=q4blCs8Zc-dOo!6A!$EkZFXDE?^@!KFPhR{C^|>t~ zPFI_B)q)b046fe(m0gh;(?c|%wk35Ik0*~$>bU<2Q+%)f&S*IV87I9WODk!wim@$? z^h%cvuS~O*q+!@i>(LebC09p!sLA(STVc5~IS#zo)-g+Ur*$3VaUF_G zbh%@t4P*sa>LY)_rz`a0)U>=H9+fF|)0uaSH?o)1pL3B-|A2YRFl>!jEIeN?DHny7 zk-xx=HV70fS6pa-Ym(#6L7J%qbpa@E;5~abVGtg1q?W$ zOFZxr8iyvpf!0*R$iz{*a>2!#{@^MXE~Q`V8LyE*9xNA@t%PG+<<^%#pzI-F#<^gJ z32P4K6$69dVRtT$Jz+dIOE4hx_HTD1NEZNcj?+~M*3Q^sOh#FyT~w$GdRa#x=GDwQ z=3229o2>_KT;+$4kwWkuWXLN?cqC%?k%kpLbC`CYgL)2u-$y_nCK1MJcnRv?`E}0! zkKN9xe6}w+G!z%{hb-xwUVTxkva~_7Y&sv@LZ_tGHVbUg6<0~aRhjO*P`x}s+mWhC zIcrh+pFQofh5ewG$A(JC)fXZ%Yy$oog5V5<;UE_jMZrBo3E-0u^q4Wc<&Wh*V+O?g zp*2N?nypsrhujC&P0ne0JvK`J8l@mSME!xA+Q60l-$jv~N|BSAJ*8ROB8&S=_jvIf zVFgvi6GQS51sF1sSjfPj zX^b(fPT?=_6G=|>IjUj(wFXqDBB)RL4}J%>kfsO`Pz(R9Uv@aM@c$`Z$R9G4du~7B zM5MLdP}}~Lkcd|HQ9k;GizKED0_6d3*S7<9e`(WdU`rjgbe&MTwWL;?80AbPXROsS z;6^e%L@R26W}Jr^#m<4(DFF%AP!$*rJ}fSh-AOM%~6 z@dOFc&*SJaYti8`O?*UFX{vMz$&6mR8$<-}ZkOD#s5;FbcV%iUzj}MJp?yGrd@+|G zj{L8!8}>EVdgfafgr2cB=`NKEOts0~?ps{~lI(Kr>4Yx(#OuHIGHvNnpRz?`kRAW| zyh2jLTdp3B1@cl zHvJard!CPVNVoY;x??3Ydui*dwaBH>PInWUX$bXno(~-l|Ar6{ccsFN3xTS}yK;fK z>0j(kexZ8}@ioQJ3Y;M6C8j&$$un-)-M7*B;0m|glO=n*8f#J>zF*Kw<)?)Gh#${a z>Rkkg?hHj9b(is3&oH;N_lhY0I4IxRwf6mWbFnLK@UAE}49eW0vD0?bqW>lxB>|lZ z(VSh&gxSa#7{GqrAriOyIYwc5sI`ER^=zaj*{mcgnHB`yOJzmVQY1mZuOo`#@WmJX ztCm)%F+RlglZ5@{`h$a{`XBT|txcD%B3L$tG(%NpHCV5+P|35fj{j82t+NisfTbeA za-BIQf(l1frN#sjco4tf#LqpG%SF}+5F1Bz*Yi{ZB;Zi5;n-nvUu}x(2|{RBx5WWS zo#>iT?P_vZBjwNMhx1-dU0>crDK5_BS~VKMW~NNk+C6?k@{jXN8CN8hg@>SdBnn|M z^>Eh!<7;||g5SF)8rC41ES;T^X2fGouTXMh1+lAnk`v)ArOnhlD0X|W{zcXMRS-C$ z*i`XsKwyHiXBs?(@qB!EUa!o!k&EFdKLmFFd?YNV4e?*p#$v=DYoUT7AQru9$Unn~ zm2td2sbFIIBOBqs3IaeHYGvK2$>{vn;#Uvi`ypP0ancM;APU@8)l$y_Kb zV*emow|iCv!pTl04n)gF=gCe9SzU{68$aJd?g4Sv-{OTKUNU=mW9ywY6O}=#U!^F? z8iQ;>5-{pyTi?qloF2Tyq;pD^oWV7Tn;&TDkx%-5`1!9+n;>gypi)T!2yjckAhYl| z%J2hBg{Z*K?sA9`;8hPFCmayb3pMoFt?Q`Sk3LT z7lHRV>5d#US>Vf+!61tEsC>sIAt1(gCIwvh4f`$x*xg7FAr1Y#KE-z&by@cI$PNy#{K)v- zBZ7x$5Cf##w54tIgCQs_3=lB>Sx|lqb-?cV9raygO%^>)nC6x_kSpHLKh(%J0Pp~0 zIv5$WBhGeh$mcMzwcBweOJ2o=_RYnZc1R+20{Bx>$?9g*e;s?BaS|{pfk zdWv{3mOv%zJ3Dv0U}58=X*kHTUM35Z@V#fa;Gyf|Lp2Pt`3+`^8-A0-?*Bx*$Xif@ z3B9$k86e16zzeSLE#U$5#6{EI08XbBSPY+jH|+3$e*>YNyUKvmRXTymmVWip9ov8U z9rEZ%3{h`eM!dcB2af=?gP8=-tv0BnJ1Y&JUs}V5hn3T1kjge>Ug#G?&vu{_Z_12M z;Lg^c5y3cMDD-G_;6IcA)9vUt$Sv9WWG_G#k2l{saL0DQwCF$S?jll;0l(sSi~Im0 zN9m(WVu&w#RDaca_Wyiapzf6EIT-`&3b$pN5nW6uIIzL zKHo>ysqN0g`V~mV_2o*u28(#O?(B3aI1MQ1ifBFv0Ejj3iAMyF#C`d4xdeqGTOAJP zbeSKnlXVhmOh7ft5dj7I;~d!dMr+pl>e{pMqmu$>N`u9EVs=G}99QYe#yi%x;BJkI zTJRq@B=-8OB&IKRm2%XSg<6Sd1-`}R(f+EEYr?eLoPM=LkK>T*Q?>%QCV31}y$99q z%VOgreH#7wmvYk@`7!-=WzP(F8z{K7#l|4@SBh0t z`DX*{sdxHW^Z4&b=O>&C=PK0`T;d5<)@(>(8jNx? zF)XBZ;A1bO3}^2OWIg3+h=CT1pBc2&ImV2;5xm@qG?mED7q~)(RJWFtPRANp<5+=K zqA4YMQ&YHcs1q;PgtWSUZ8U^Un87k9nNp?aV?Z64oWB|T2FZ80jX!^L;r6U$-uf{e_}yIi zL0jP~CMgQySx{NXIQFJE_VyGtN^wT#XAm+wHV%fuP(S;P-1?PFfzAwo`;J5N79e^| z1Bq^L3D;+=S>81)xfBcgmX1Gt#_k>|Tea`k<5PU_nO?(~zQb}{tI@p1@O%bMJXAG1 z?q!6w>%n~u5MLqbF}&(O8Netg6HV^xQwltX1`+Y!V4Xi1j5WuVRu92PD4|hHxmLQU zP3o?tT?RCt$Sf`+IDYt;5Jl=BtyaR{00}@|aKM@kN?X*)!7WZmK(34l{hB|{?EdcL zTu1zU+v#wk7szO^K^bxLD?@*c4!cTi!4(Q|uIGvU16rak=(v=XT^2T?i?mk|sIs(` z-t+Gvae|(F^#4eRO{hvWUzRGdG)|UG!ExYrm6Q2hi-S)}yLy}%9SCMuUQ|GblMZ(? zAoUEi{@?DG*7JwFazJ?son2NXA4v5X>lP1j-aKIU;lZ5=Yay%g!~pOW_`Sm+p_Ag% z9Wo*JBmgWEaqAjOY3iOo!rJ+#B*y)JCGkY)QkKk}CJmqWkJrybDtDTsOD9tug=zpv z4MqH3=A|>(qfk8qFx1+3d$t?Dx+}noU0nEad8K{?eD@CP%f#euH88u6Z$rhn6wpz` zYD~34O5bf&wD)ou8?;zK?dSjmPe5M%wfmc>8VL>M`dvcds#7NX20<%$1+lqlG}sj; zGt6?Y`U(5J=5%TEaNfjXJ3!a_atgxv%QZjk%g1?FTcHUEjLlmHCGK+btTDI)Y2hc< zU{^m}rnNTQ(N?gtIIzP&0>}Q0t zzXe(*xi=Ht$pWLaDVT~ov9eP}?V zDe+tYH;Co8T)laj8ACbUK=Zf70wXw|jXu{AyWg3B&|tLB-n1c(Hr8V0nHEQG%DfxV zp#RwCNz&@oOi{nOvsO#1u!ozX6sTD0gIAmHPV!e*`Q{+*YyYq!b!fE?*VTgi7M5Ie z*+09&m?yESgBl~Vyl~prF!&7rtmZMMHY}wDwvMBnD6(I3_N(^qn?^`ffN?rOmB` zr%94lYM7A(JH#S`!zQ^oKP$n!PzQMqw#5`^dZais_nWrhIw&w-!?WH6V6VjLvYv7Q z6-)V`MKd`L_8c+wC2*$~>Z_oQ>nt*4e&FmNvU0|?QaZdxIHX!d)BTIgUQa9SAcyAR zo!yKRT=qL7dovU5V+yT~i%22Zk1GjjI~oyVlNQj(M{Wp;y&5YzGFN}aS|Bh-a)n;V zZ&*mG*fA}^0QOc2xmO@Oh-qE`<)j4sBgkmg_ZFM*=FWhU+aF{;+;i}1;P1oXpF}XK zz@X|&D*{sg{R{F&!8vLAVKDNtJ5~O*49@p{ur1y;A!;@OU0rk|6()cuU;KXH3R2Om z9Mj1jxlvws$YZx-x$`;uxvOtVSQkd&bN?nqTZW){vjviRREEKlDm|bj#UdQ|N-wXR zdyU-5e;NnUckLAu)cXxHno-IUk}>Z^hECFj0`@_Q-np}|%FnqH1*+v=iUCFYtm~+d zNgTpnXzB)_xaEhPeSXmAguDq}*in@npSH7S72Ghel6-%r?Sma=>zgAxFRz6Ky%M;1 zVp)yd4Ld`-$@2RMmVB27W`F@-68&8>8;KC@NcV*~4?fsgbu3#{9Iwt=^^a(y+hd}#xUHHsK#PVAdLTd{-252KSlGZ|Nqs)Uxz4+rU;FUC(kaH%%C7>8APdxU3K$)6 z0mEa@Vewl|GuA&I@PS+o{p+s}Qil3L)dJnpxZUgWe8?M|@ zViE$~n!l8bHR6Lk&tXUVuDVsty=4_mc6gpbZ(NW6jI$q`gl|G>bXkI^pG zy>-0xb)CNO7?}s5z+9vr;iuE3CU<`qWF_cwYgW7ff!L#mjjxkaF|a8OyAx4|063%P zj54LXZ@iQ=p>Ao|IEZP*yk@4OrTozhE)aDGJkU34ekY(Uq)V?R7^ff?en7Gu7*$Bj zkz~%PQkH~+O^l6A4H`lo-mNL;?3f}6{g5ljtXXre<~-$M(yMEq(}CgB$Up&AOK zMAgHi(KFzL|BG6$Tyy0*io8EK6c|CQ2#9@s;ekrOBLka3&EOxvwr}iBMAFvC6efO+ zj^n)j5J?Y)luyMfquEknt_APD@EcmUh}M}Ded6_m(J;mdi95~^twx7p+dAY4nsa%z zN^N_z!%82sms*XW&h!)`=aps=1=dg5e0D%ONj$SoC55K!Kx4UYyXVI#jF&D;#9_mHcPxuaU%GMqPYH?me z97TGUF$R}j+PI4f(K2egKehGXiWMVD2!g@NsS5klgc4+?2?tgs%y`aOwjTIHoKsxJ zalF9919F(dI99v|CG_Qj>3rTu(&*%b*i?!Vv*b(yFG;b}tZ4%ZNm)oct!U#b+SsM| z0}doOTnNC*B1_%?Qz+RLm)Yq=_&hWF5#uXFwuVqfq z=rAE+3x(4AP?T-vu7q3O?JL8RKY{ZMMqCby=HWC*UDBucJ>)LGM9OQ}ygFM*eqe2nzJI%t>cmzme)iD7gKXU?;E= zMEzL+y3&TIe|#E+?&DzV0iN!|lk~sYcQtn3m@k9E70Vp=YhfkfZEXjA9b@!Mljt4# zbeAu~?+X_5kJ$s2zhJsc!Fpy}H3&b)!0x#4ym!wD6U!v*aetN@{9))=VLMbd=euv5 z&YuneI-PIXy@W#d@U*IOv}PbT10NkW0Sfhh56ia)&-jYJ3y9jpG>at8gsP0{;XOrp&!s;fdPg zY0PmtIAQsBb+@sLoAyqNnA8a{r~pC{5voSXf5Cg2|KpUp-*c8na$_lV76@9<+v7zV zBi4=M#pDu(Pp}nB6Y{Tc>XXbKJmU_-Gp{f4cTK9D?r0sip?dt8H)+U-+N!bIAPl!L z~(nGtbTNj7Jtc8x0BMJbrQrRdupP z3C?aN5#YaWzl!am4#B(Z1lUP+ocxcMLv55Z<AI@LW?;K8u&l;Md%S9(uhh za5=kh+Dx|uiaQ!#ww0;}q6WXV=!4Rgo|w*eUrbnjD>Mvo|5gC91-<;Ceg=vj67a=H zn)260HlUH3Nmn=)@dLwCTzG0Vja*iIXTLsmI=Y^c2zoiaFssI!}?8<=v85nxx|PfGO6&$qbf_itEzK$;xBoT zJ9ucR-p&VB44GeAO(t^-J&uKw==PgvOywA>Qk~b*pjaz*mbdRcK7#twb^{pf{(U`T zI822n4Ba~N(E`lv(hGg@H!GI7If1;}>M=HvQ>@0H zsaMW2lh62Vq0VEbO*XoyTC2u5Q;$raCsGrb0YYz@I{=i;`0bK>z!mH%t#lNR zvE6@?wE5iel8!!CJ~DNF!zZiu%@fT0iFmez5zuWF_5DW&nU24ou=T-qKVit1s zd@jr-mc*VqI&WfTY*_7Cku_k zm)^D9M7(X=_yE53@1waU-RsM#$BQ=I7Mor@?p4=|_UWAEEghP{cgB?r*0rM&orDXg zX8o#_qZDWkADsAYxfbX`0nHOfJ7C9O1nr$`{gtP-Y+xs__UWp(y-vPoL*&KU92n@x zsW2w;!q)1$XO>UoRnvE6$mY>Djd}jFk)zA4BJ|rDOO2ygJ<7GGNF!1zSSoMHxKO>) z8tsw0&PZ#@)?3dWx#NfJhg;3cil$c~B0c|An)08K%+5kc)$lECBowwBK2^NW(ialLa%CpZ(%v%dAMPk&;H+f=h>hb;3N9qB19W&IKoDZf(0JxHbHDe9N^5)r1o zQ2aLTnEQ2BbiArP`^TcxhkU{pz`uORWa0k2Ti(7r$+RTC=zSHyOe&1w(PE45m6sXrrD z1t;_BvR$pcC*)Y@+-l~9jyXMSaSpT_gFc#U^U$gikA3+Gir~`P&X$^}&Fq@4LO8c} zw{-;Vm32w7`=vBKaBs5IV z)59cXiYs5i@O;LXGBReI{7Qgh3#oPs^7V1N5yf{5-W^KvrUWLRk-9#wC%A}h8Xcz# zQosBrd1L*_@{mT`9;R@~#suGSl5HR<&j^uZqRJx-TkRG*Mt{AZ+KTm%`*=j;+jT|IMwBcKar|BVkl8c3TjWqPV?>13dwDKr>a={ALwsMG4W`WIyr9GWqY>KS4u(fA6g;LCQCW$$xkkL@GSv8)(}$-Pmngeg@y{OB??t%I6OJVw zUr9CCs*T0V8pf#D>q(P}P2hCARr@5ks9&f>#({jB;PkJrZ`G|6H%_`Ycb_XOJ=x!% z_ZXNy-aH~cD{|v-XT}fdGaP&;way{Ezxu!3HMI>%P9QLM!fE^kpo<{BS-R$_lTm=v z<>~qlsp2|_G~M3X`@-IrR#|qu#t{qgQ6UPZQO`olr*WDEpF@OV+69Vqi;YPUscWg}V3+6Ts}IW8toA8Qw>aGe$0H@^uF-QT>OPB?!w$sAD1+16)J@{VAI8%O4t zwG$HYu}Yq1P;o0@cXZa-`6@ALwyyc+@PV@XDO9O}CcFC2n&d@l=Ev;kXfJO9AOc zuLopB{bdJZ#$*Q*qY^>A!CV;Za?w%XwHJDP@!0oAIBdug^DW)ihpv>-rQB(@i=%F} zxS2?225dyb&%Dky!}Q=-^Vm7vX+C#StiPU#p2TH`M>uZOAvip@YR_)%d)p)}xFaMX zk3&zh=8#hO!rfxdb{Xc`-xXMJ`GEaGg?%25%|W{Gc(1^%+xW(%*@Qp%xuTZxXsMWem?ijo2hs1IdgB- z%s=W+t<}=*?%FMtmex;MMd?FOfBh%=pqy`>3NV7J%6$jHM^Pxl`W#mXN|D>ww-_3- zdD-~Zf~+1c$gDokkh@2kTdmlRNxND!Y&r)F-voEj;2?SA*%9cN%&|qj;hX*ShcB1e zk!T|z?_3HqXIcmP2H9*FFgk31>I9f`ybPR*2v!m~e?A*La4f3bb+tO?;l-DTW%anJ zgZ8jvMZ*@)e6l+(=;{6&zzNs8T5d%ogkH2z>Sjf}xl45;-YA+nO*AHiB9$)oi>Saz z3MvR0SHUHY{c_CTs!#Ccf{ezWNZL{0_pv1M8;u6^M5PT762SgP9U99!&yL8sT14W_X&ItO7;w z9_N`RLw6n;wX~KEJ%0Q0D~PqIc-)b4kTStK?I_})iOb6MBAkGOmReFw0}L_W2aDQ3h}G`xCYqjC%ha*Q}+8Ui|=Q!Jr6ejvPKXj>=h zW8KLzHyoV3$_#32U3f`lR=r%W8H`YJ{1PNf4nqdWx41I=i^=q&381{h7{NrzI@a%g zD57-AmU#gHQ`H&+{TO`MSg~iak8bSWuEh4=?oziVC2dfLLb-)oty=N7VF8qijI8nJ zMiRWFNio}T&5PpfcHkY`!-<|-`O$@V$csJE&3fXt1juFt#J>oDN=%G?aE=05X3mMO zN^#(&d*Rk~$2?6R3zi(K9;*{{ScFC{l^j!@OtVk<7-cLGkr^dUw%r%}5JA2<3**Z7 zIdzYtrD#?9_YAu}f&8ti=jDQk!jB$_PP_%`ouHx@&awCmX2!4JsG@o9w@CcDG3_46 zHL>7S#0n@PQbnOj`~A;0_xfa`0X`xtpe z<0YQA=P_KC#!%DcJ%fGig*TqiutSK8{fUaS8me3kQPw33e{DYITh(EstsvxN5O@~k zdWXbFOl8fEjSlRYs4tt%6&yN0X?Q@4HA&@tucqgwZ*X3rRfjFtGU=}oxJ2-RXhWbF zTY3gVek`FasXRmM(gS$g?^9@qj<>{iB~+~NHDgDexTM>C7#p^Qu)zX9hxl|ZrT-d< z4Iq$jx%eHlhPDAL;7arAJuY-#KW%XcpQ!e?xl`Oo3dN=L6r`QbJ#W%>d3ZKf)ht{a|-OkGSuAu-&*Y_F)53n!T6}oh%4nl!5=Rk&NL&n@k z3^^(7z!&Y2O34T~K&X@2iSkM4t?uvVt6ElRd=ACEk8P8$HXoKWP$S)$dE-H;d5R}4 z6PMoShHVd%An94P_T$19n7^@6*KPO!`o=(XC-x_rrxqc2*lCm6-w7}iR91^^EjY)| ztu%8QVQ8!y=it_D2+eIERlq7?+uVresYW9c=XQEMvlDx7>9qxS;W_cNX+5)HYmLXM z|I&LkWc&5*Wld=9u+t-A!Hu|iJ_dxXjtl*w%;sZRnz66_*#6D?qyX`ACvEM$Z_sOr zm%EOc&4H(rH=H-!DN7FbI-iP_Q)%?ORU-#Zr-Gly_qnyW{th(f zFz+oY1HBdTxAPR7^)sJrXQ3erC3Jz0kK^Tv z%k$Ex`lPm-y)3}c`uka^qUVhT5q%rvl`tA8;eFuVGdym%oO*pQ&)vchX+1iz>m#$= zn7+l!Lt_`tE2H41WsNgv3NDT{8@YCdLs%qrDTqvvh1`;Tjp7~{9lVMv&3q#`6-^Q@ z83;abcw1{e<9!f$K7g=WR#R#Z{vzki4dKx)%%_AGyAI%1o(BHNUpXH{xZTOnFQuPu zHs7CG8y(lMOL!-73C9~jowB9mo6Xg{J~$fo@#H0SRQR#|=XH{z+F6^=asDO*!rN4V zg;_wG(~x`^?$lpbJ@S3A=W`^xQjH+(@hT5=v`X zc2r||OF3ZoZCxdQhF#Q?qtPkSvo+K_s88si;f!s&A>m=q-uuf=N8?$?(->Ff+gGuo z6=$7X+vI-kF~6K<0sJ$vIt17+&}Ko2(eljT z?x#<)7MqDfm*2m}qS;Ducm>_agEQyTu;=}7`0@y>tgfv8R+O;gd6nJdd7R|Z7$BsH zx}U=t+5Jh%r)1qlc>Djog)eKfEfJ5meYnjPeP5CES1jNVSf?MyWZigUwC;l9fjVz${#bihIY-0EsytOOT3L8m?oIKo^UBKzH@4-&p6nuU`#VMd5<=f~vO=5rgJWMVU)4)mqTHKr&;#eRMGBvX@@+d_L27DayLAR?*n9{oJ!6Cr$2AKJ(gI?p|i+ z{V=lf0JgG3y8Z$hVfDs~vf{rx5(kWX2ijFT-9U3Ts}5eeD-UMwRiayy6YIxAv?rU~ zj^#KvrPTycVpUc5Xck&x@I+v8s(3RpII?vTmssz%{`TX?N}pgTUu%qZD|T3RmK}*a z7a>x1xzfJKlxw~z=Pc!FVOhof-MC*@V0+#`o?2kr%T^Vud-XG4;jjbjP$~Os*q85? zAh%m=7wy;7C|MBroMX+k23g_p`^%V(m&DZirP&W1-Q#}yKj34Nq4~h;b1eYGOxG{;5r|{WG z;A5mE;1Q1_Qf`U?&!jdt{z_q@u>>V3wR1$4e(qIni`5QUX7A$!4Oi~yQ5Dr!ONXSR z4@1WQ>N9T?c}OS`-UXanOe?;3{CJzrw4_Bym@LY6z-cVn+Dv}Dwy*Cp2Hh+HK7CIV7*6Iz52Xl-lZmQT_W z)!H4o;8Ha9s+SH(VFmb;FHy36CGKl3%hk4w={3epEK!Uku!C@d40}zk01nNHS6S1+pxYH(kE8pu2?rD?82gOe~rtE&{h1LkxBhN>A2Crj>H3dS00V5H@? zjD}p=dv05N!=ecrYo||l1M-R!G=Y2zi4~cmDyz?>+CrMY3!UyE8f%t?S&P(^|46ki z_k&}m%$*UAnX?&{!)fdrYi%o3*qnPJ+=c7nyygCE7Dme{WGVN0wXXYsK{EN&19VX3HfhTW4C1yrg?%JG2UK zyR@n&FG`)}?!p~O)v(=$(lF*0Z^7;yn3@0lSm<;ppgGceXH!=~kq;@Wq#8Gfj>a>f zga)~Mmi#{+OicNDTj47dD=wQ&;rqDTA_(EsF)7*GlR({*?p=@gbpdz-?RUOiKUVIG znic@onS@pGo1Y}vPG)%4FbwooDX?*DvCQ2=1zFoCCcYQkcP?0ILCZM;Rc@?qaPJ@m z(+nc6O}zdar1x{ie{lA|9=~E{i;uCKdqvlEQ%#> zHozap2N&|`!vcbS&5E46C68Wj^Q(gsN|&wm<1)5ll0 zy6K>HaMNjH=e5r~%m;RFrbDVrD_CVZY3vw&G0as;hKEL;NW?sR?{_2+zx%DvmKZZ0 zm-PCUcTG}&LbI8BSu8l0#rY@Drn)4c1g%7)$UOVs04;I}l(Idqcc?OrQoY=qpfcQl z2H-zVIVH=nK&xLq7(RBQ6>H>~N0$hf{41bEDTeB9&A_96T_92pRiaU-7n}PRFsDv5 zw_SIm3t19Z7^Ixpk#0!xTrpRi0#%_Jp&@ng<7IyK6YGi6=$C42;D8+uXGkaWP|Ls7 zwzewKp#RFAKiDlJp;_qTD})upQ-LN20cD6l6@WsGld2zdFCGQvM)Z@rMwrG_HdKRf zpd{#Z=FZ!yECs{0#lM`m=3{w`+i!x}mBVl$7ElqRfGWn33zfd|K{bXQ+v7KdN#2XpEc;998LpP$# z;&;2J5AV(u-h#?6wvF!w*$#~7kKSlzTXV$Y+xJuWc%q@mF_Z&iaL?u93Mo6q<*SSuk~MbLqb0XEcHa7*4XAO^<=Zau`Kku zYzC;Zmk-yYeGb2kxC%SMK|$@%Kd@yTkTTO#3J!4x7v1aY4Pc=Ov>o4oNtPV>fKa@h zLVPgomhVkWv(gqYvnb6$+vdUU0?D=(l*(FSGhfH}Ow7V#3xnhFGLV+AFlh3fjhkNh zc+IhZ7| z{%CC0-|7Fr?oq{uo0U?&yrGx3D!Dxu3*CnHg%F+L%?Mb4X2ufK(`W1Qbay!)`xg+) za$dJ#8E)%@SzY>@Z^O0R2jjLKdxCL}K1?_P?3B44-dQJKwl=kAy@D^Ty?!mbX7YJF zYE9xX@0Jifmw!(pqIu~RG~48H(2A^^PX8L(j8mkl>B*~p`-f=~SZ%0}80Aldx5xce z{sq~h z;7(zH9P#g=*H} z<$`S=sU?!}r5S|<3al0Sqs48cg@aw?o(U~D)&!UMfX_PxLSM}M@0h2$U6Z{m0mrUg zCyMe3no_u!r-92!z7D?w0wG@2ylaS>NBmJsdvN=1ydtLEW7RG&RU1N1jKjIU3$ISg zk=S4(9zcf-FFzY&`tQXk%7m*ncgRY88{uV!XM@iHzfsQw^6+a-v6#$~!Zo4GjTvU; z_oubg;BTHHzJqEfr)}~o(D<(Hz`^h3Fv=-`lJK>M1HHE*y(4GZ)sb`~Y?o1i?m!o0 zl)S|T(I zSMGcpac?$PZx>qxx);kEg}_$nu;DlAl)iNaWOXsQcra-~2}Oa162O*bfeY+!up8X7 ze!fIJPP>H=0^PC>UmzVwk;XbPt9QEwlq;MIPvYiIDZNH$GgURe*?7ak+B8|LGfUUU z6|ppGvSQA>^77E?aD9c3SB^U%cqp(?H9Qd){*!R?Z1h&0W-(*wTBDLtdm~99+S2CD zBHD=&$N%b*OZn`Z9g@!lf zVB1;8AL5CYfEX9NTK3zO3p{X&-)4@uufprxuWah46x%X80Ejs+``SWA9__Ig>+9CB zbJot4e*%@)Gt-8s$F)6|RFIW-<3B<9(^|FRSBKWIQ`W|nRqKBOO{Z3a!Pb=9e(?z3Y}8zq)=n{~bFBNyLTdr5*ZO_ZQMHAZQ;RwLq&Edw^KQ!*TV|3rJDx zYW(Yshz-xe%S=l}GdKp3TCN(K7qeV(AWKT|U!X6iE^$>2UdBw1uO?L!iclFZ(bn+3 zhPd@3ehE!`kcoQ2Gyg4jwSz~K%AqUL{y)-MQpir1MAYV;Xgopp#UOT=YMAZ0Vvh1xCO!P)VN#wty^c0XCEQo z?i_(sXK%-GULM5n3>^6C2j))ob+HLBgo(iY6?QaccYAFW%f_SID%OR=Bo<=y9*3wP zz2!t+{qO@f56hvO5H_LNCCuU0UhZps@jiF60HFE zJ+OI)ZJ?s}N_}nuIZ)4rt>a@6RiP_7FZQ=FqG`17xS}hOEY@C^cDI|h3f$vxtIgXi zw5+Rq&&Z;(i+4j1FA*EL^9}Z7%1BDx2cbHchK}mO!I1LI4HB>Vs%2AwJ1k)PNPop7 zt?lZEiJK}_g8^|FtE>e4`?>68G8ta?A5${#g}2zc8Ox z7&~^*2#woI2u;JS+x39z+z#*dar2C@d*m5~j!(BS6sk4r(CA>rre9*Jmx-p6yh)NaWl%ap6~~`YAi=-!iGY$>zBRM^CB8*TsO89XRII zWh$YUltBpcL6o0=+_5p>&U1C83Bx?Rp1dAON!CI#^fqMehI^c%zr4pwjs6Qv@nqNH z#z*y`X_g(SDol3;p3% ztmW)= zgSdWLQqY=oy-Wth!WYK@(!Ve!n(M55R&;g|JAC(7sg0OsCaJckI}}_Yykv{<5b}33 zp*zV#6+(ea=p0hi7e(8rAs8es-b|c3XcKqdC$Jx?65GDYlWWes15rR;aJ`^NiY8-! z1qn1J{yAQZe^?`D0}O*fYt9#xFj86bgBN4R7mAEi?ImQ6KbDbj3OkqtCSSt6}W9kc!(~=$h+luxn2mJXI+j&CH)2HUlC41|ZZjqC;s)36lL5 zdsH_mJ&K#d7U6tBG|cl}Q;B4YaE!Vy){6nlz9Crw3ms|^>oEtSoHM$3AlIh~Nza1l zQFcVTGd7{sf2?gY}Rx)Lwc}xA)c%Y{; z|5f!R6wG_0%h#hBl(1H|Oh3FiC{5QEEB5V5qylO;H_)Il2v9dmw!hk{_d8szaIjnp zNX$Xb8#9K6hIa=KN^rc<9y`Gg@F}k0$d}mgZ@^V1|W=Pp%#>mm6|~1 z%Vq47vcFuQWnIlce}hpzm<<+PQQ~y@%9gP)Y=AoPs-P4|Of$Ha*8Izu-6Q6_PXJz6OD(qjGvff{eFofkiRZuOD`&hlvK@z5_gc` z4QY_N)A-b5#flgtOcVJrpi%XyY%lZj2y0z9``G{K50eZa(N#Rs*%+sohW=AkQP>cE zvSz}5RRvLk$7yroT3rBx1e>kVkIc6jE`n)eos>4$7Nr90w zgSKnT`Xx<~^GA}Wb59i3Fz|NyO3{D?9{>7YdSxakk<@%=N^W|3)5t~&3sc5V*9HDv#SJp_sBxX{N zHW`2H;^XyQ6`a2B!7~{=!m^qAw&S-&QcYP5o*Ki$P=_~*!_I%f5XO4&Hw|0Yr<`#6 z+@{wK$(Djn>tno_&b=M1zD$+kgid)V4+A)T2k9jrzpUe(*CQi%{NRu{CuK1W67n7n-HT$J#RC~-f0<#BtLCpE;UK-2Jk;78UNyc>jm_Sg4N!58 z@kE)rJJNBDGFX*nV(~b$RtOQXSxB;r`FsuM=>72gR((ylz{i@cwSDU{&QulWUC8}D zzOmTUzF*_T*X|I1`W)?T(ue1`)RC$6lzzo(eR!Xvd%iVS%?*-udQNY0k6X7?o0#!j zUB&3$THnowfhq6q=`*8yU5<}&tHld>rQv-on)k^i z8S&KIo#!^fNji%T*b+SoTLX3=SlD)4JyUnZN@3z0)7WydFVgDY`-l2i%fb5fpWtOgIO4dz(&f{QJFxo zFu^%WsYoV3!);g0E>C7h!)-RmE=#8P$UA%-)72akaKj#06omp-QpD9Z!7Rwa#d4jw zd*-ERh{6{ip*5J?03odU&q#H~&cxI83sQ8F1GazUb^Vp50 zWn<0{LKyMmqyP8}OO2-;o_DQpQ?l@stS$Q2GkEgox^Shu^f5`##dC$^ULGRp5>cp&o?er$o4jKcV#i2@fF z$_r9a9$ny}jm*O2;znKd(z}Cko1y_LeZM#`{YW0patuFNr;YKnWEnZ6|ACtu9e&Pcf$6 zs{3YdtkO#S64+6)fzx_Z(DUQ9LRF1Wl-vJ8G!{xjKH+qi5mPJ&Y7H;5s=bL{#owEv z%p@W#!%r0PmPMzo)@lw?b9)O;l&-FBYTG5;z(>9r_QFQIKmj){Nr+p4Fp9P8WP=$w zGdH8uL_9-q2(px=3&)&ZJTMYi`fiRryS%5kJl=HT00=Qcnzqohd#s>w^00FSKcTI) z+Lqdyk+k*Sr)$E|;>wL>4hWLV1>t>k%@jg#z4^p>MARtxiNRRCo{#75+j)m$*n8?? zGvax#fF5WVK&knz?4tESbi*?%=m?Iy@<9 zX@qrq@d;&M!@>k0b(X8K#mgV}=SRQ;81KZJi{hhugy-M$ZD6l6QW6D@o}vi7?WgX> z{WZwKy0=-+G}j&f-KMw>Zn;>Hq2b-7IMn|X5Qu|&nFzu5&fXQ=gA_orrp{MIrrk6L~0a4;EpN}8#C&VK{xYEX!6okIo3MdNT2SA7jU_CtEZj(<}KsSf8O)51yVWseA-o52X{lu4E5yn zqzyDr2IRl&fwbOEbq_`dA0I)Ro?HMN&PXb<%r~RyRjmpTShJ1ofjcO+ml$gwU6_GY zW;8}rdbncJ#L(#S`=(M36@=AlufI=V{=j7J_q5gG2X8FfANvpHih@ z3RV$_h>m9iooK%5(8h#G0eb%mAOrLQs}N6yuMla#YoY%V(T8?X|0mEVt~&Q4q7O-^ z`F9roxQ3bnoa>K}UcWtk1j8oU*VIX$0rj~dp!4juQj4w5{%fWD)5}Y|dQcYu^%2Md zR`6md(_QzMDt}cU0$Z&Oytvv&$emzieQ$;FE>AY8I$oo(GTt|72Semd)ouHa z3V#<>sI@ilUyGvwvoTz^?x)faM4Xx+G80OC4nX=Ey_5R-#N6}s;7r2R#pq&<1}Omy zD7?d}_?1&T4W$@h4B6>!+o%)M zz&!e3mMjN8&}XD4^-?$MB=V;*)Zf@h@1n!`w}{Z67~TCi`cqyCDa~(-zka0zn4uM@ zCmH`IxEt@S0|2N1vrz#aYN=ZKu^wi)ng0yyZb=ExRqUt3F?clp-UQcih(=m z-!*6aYq=G6=8dB~nVCF@8^p89BP43(0`$+U0qHe1)@Jbamq$O~Hk0vEoMg`afLi!y`2eW&;$+CdZ+MOxHEejmGP-;Cs}^KmkG2wYbv^IJE*FtuT{QQL;x0B7j60$ zA-Ic&F)OzI+@*YpD?ahNc#e5-k#*n7sOZxw*Ze%LHX_byhIFEhBCh!M^PzpRe!=VOumX4^}!tA_E(jsp4a{dj!>_Y);P^c%QL+^v`zRc(Ut z=^iDiB{YFMXTnSuA!@}c5Zld^XA`;Qtn$OHM0vU?X@Wf2$Pi{N<_HKH6 zf^>FT5@eYS4!@G-(plZOFUZ%mq5RUm%f$4P8`1|+Zm@HBB4fhGqrfT?1bMVevuk); z`D{)X*?ZbZ&LSwvzP?%9%4BdI5U^k1zE*V5q9UR$^>w5-feO7{CVD)B8_rFCAJUlX!bCS;Pvk-VM<`Q4VDDc!s9K znW6XF#Bc&JP-l?t27)55jk_&2AvOX-EN?AZE|T;yyfwlW(|Fk)!!?Oa&g#Pg=*?8`@w6n#;ByR+J1;5u%eV#x-WC!QYUmXt5lv41O|EOI+9Hz zB)jjcYHRTkfyGf3d@wwCn!-)fjG#fCxxr~4S&ae6r?>bV1ZzA}PR)49-SpLJe$2rr z$j6<(mDXQ7GBwnS$O?j4hw^z~iO62Oq*{o`5CglcQ<$T_eIJF&)zVVKQHXnT^YdsjBU%GBU>u4t zTqOV@8T>pKU!gP!FsRw%L{%U2+c_ew$8xc+4;o(3ate!b8VX67m_v7K@AdC5JiAhx zt#6pyndO2q-Y{otZ>0iJd(~fTRyA`YYVS}z)97kI#D~askU}ZRP+n-H<+!-##OWJEsOk5b-^yboEPss${`kctNYo!4(JCKu@a&Ewqt1Xl{nnJtCmi-{~5V z0yl$^I@jcCQ)uCrIr!8JiRd@|YA3iq82%a48R9d47JRFmP*;lHA6)9uK-)jsYTa-9 z(skxb(R;{w+M{UGZ*d7UT#HrU4OUA+N0~Cv7^p5*oNZURxx$^{7qENk2|jKKvXVN5 z3BoOJ1@Re(Edgg~cszEQ%xxonJ*EAC{X0>ran*Pla;nM3w{$qRDY2DerjJj|uJv48 zMEY=wxoJ8VTaD)2j$4`>IJEu2Vzjj5-sqI}rUl($bh*pHN_2;9o+L=+>fN-k1NUSG zs`b{2^b2A`=HmBRUNf0$Nx#j^MuGlsTaUkDL$qOSftDe^Ld)>g(q+MK1cRl3$zjx8 zW`dQcE`djT@Jr|9>QsGiag@*`jlUwthd%K4nXaOE86r^IC68(dk@f>g<{jS=Do272WO?*~7N%-U1X=6m zb^{WUMOTl;PsEfla38^1{SAW-67KS-*GZoOEe?-QU0uJY)6QGo9h&S74cx&lbp)b| z|K$8Tb#zTixWy41$TnZQjYHgy5O}bZofg!+Uz?X~pb{OMB?-gb_MPwcjk_H&WU8qH zr#M;6z2$M=$lWi-@)1`FP;};LieT1Dy|^*2Ll1RcMedcWy&#Abg0DJ@1^QEnE@1uW9kI4t3F2;Njt@YPg&C%q+{ey7 z0`rBtdNo$Q$IvIOMqqy{Ot=RFmVQhLUxJaBc*mU}^$fzxzw?#@60S?pM~;GSO93hs zeB~1EOVLmtLZ3CqS&(FvsTR=`#uj6>PyzZHba|Ae_P)3cgEq%k1~tlV%XrwOGQmRy zTOogI)RkMbVyIiqUu4^Jl{uu6#nY`&b0&pMw7oRG!%vnFPz-i~Ezf-~tsLPP#5)li zCKJFv*&5|Aq0!3#YSe68mA|Qb#_qLy@(orD&AM#aM#H9v4zgONWvS-l*GoiQAnMQbcuLMJxXaaa-Ajqq zqDM%+94p+mdT3R?NpJg45DHDSVMSJ6hssr)?-w&YrRcE$9aJ4;6imyvRG1s~+%2m` z#Ht@*AH_AfAM(=*?*4%BWB$YS{79s;#%>t>G0|w$?6o-ht@DUeOaf84dW~P|cvnsO z+Q~ub&@}Em9(3F}1lw1QM*l&Zw%;aiDv6gcMK%UAKuw$--yl@)0{T5=_%%v>E22g} zlaUKyQHlvry%x}1>JAR<;d03K`e16VA-9&&w~Uzl97itp&;ER5Q0h5f{^u7iv4HSj zYvW02-;yNURp#IE46V`}va!hDGg!}4xGOSd9xKucA)fhlbyo{f#ubSQgG*$ILk-nx zb_iGu?({K9{p=!U!x;F?#%I-(f8+#-$YwwkV=Wf|lVl;%WQo(3?Hh()lo8Y%YgYW3 zgyCeJ2|oLwWS+2YN>M^mcyI5LW7`!F)zz?_NWD62NO|FJN$qMtjyQnZ9I=${IgwAa zlespl3@qwz@KczJgD{{K#9t^k`u~Cz7~SsoCySk*Rg1X4&SCLEgmIv)a=^D zIr*6YcN9BctjSTwgz>WC!Q2oD{@c)xPGb-Rd-(EFCw2wj#4^ppC<>y;9sONobMcY} zf5#qq&3Fb**~t)oZV!K@{lL2HfJAz*)!Oz%z0~igjutFBLHN4Zl^A@dZ7X4I&#V)Z z=`Pwa${p19yEcMubm{l`7A3ek+BZ3o=~EeC;Z8fqJ3f1G|MDlu_grD?k4>{TMdmu$ znOXM70_>-cCC}!%&BC7Os0K|9=Q%MjGY_=)0v&Lmes`G|aK}q+jgrx^2b#}bz59ql z8QqaCzcB1NLgiMuuwQ5nWjpwE$rvDHxTxa%^;33UTYN|eVh)KTdc(x3A>Nk0*+^ys z25(m=Y#XC-U~36A3_K}U2RaI7HWBrvBR#2{yXK08gGO4Akh6^)bh%ac(+`aA@a7w> zCwK~2L6~>CWWA#Vb670G-yxqeFpn?&`FXX;(8^9`K*utG1l1nnnV9` zB-4dg#yk_47!6D37Qe;{Hd`A!kwadt8lPv@K@3{e!Z&`kM3UZYls?U6Hu5ut8y`tF zcLtwj#EexyZW>8o4I$W9-mBKeH|Cv#d9I@^ln(65vpHho!Zn6xHYOeq=@C!@3a5G* zxztchl4jM1{nFz5or8(eQU3Vh`?V;}#swg{)D}~2^8FeQF31teIT0zMz+SnHMh&Eb z#D#@3{At?xHa?+Ncs$S@k6W`cqqW9yDIuF2oz~d6mv*qe^ps5X1I-?LJXp{rZ70n$ z6Co{@2=CK_<9!r(%)6ttke7QfP#NQxV_ZzN&Cc50ZjrFDNIlhR{31(p-p6gl@>Nw< zJ9)e|dA5}-&7@ZU9l7%N(i^Sif*H@B!lXM!JXzy{HL2EQXQor*ad4pa>P5<#s{QQuS)_73zCn**wJ!qm9cq;WjhxM-Ze^PPVRA$=Xdp^5nK=EB~GJ8X7NWA?l7kYceqOM|KDa>uT zmzz&4JG~H$3LXP9*-MrOzF8LO-<}`x4qQ`2fED-$p%(dn_oY&)Hx_AHk*~29drtwl z2OZsG%&2s5L?=Y1t{HAH`LMXXfiXUBVmFViLK$vI!)xU@&otk|d#7MK?B1K}JTh;! zRmiW&Ay8pEP`tAEjR!I5u!Q$oa+F9F@82h&==iy#wl#8&7hf@f=>CM?8|3#cey1BZ zt3`YXE%^A03*mJ>aMduKbas5`VH_1Fh5oh?IOO+tM!_D)*`F96S4|8L;oAx)Bzfq< ztfrT@FTA;!ZgGP?E4a1Ce?JamR_yeN{^~=ZuCh5L!fyhoZ=FGFC2X0JE1K~2=Oyrx zQ{QD2UUxNFXcgWA{0}T5N@0A|4qm)&cs+z(U;g4Fm!uI(R;kK!tQ7MSGp-Gs z^3i(hOyt^k93>~08XxvGmyJB;7N;@%FcG^-jfrG)YNWe3d70LCd3|g5=LBb^-6Z?L z!_V(;NI$E+l*PB`Kf;l^TzAa89)mDVsDNN2xSJ+PN>)h)c+Dh#hbaI6uim2z)EBuh zgV62s0OSF5IgV>Y0v|d1kZ8)+oCv@J|PS%jW=dl-x10w zwuJI$C`~p`Mo!E)8B(y#tIwem==P1XQCNG^+cD{^8kQ9w(#WAu%m>wGilE^J{;YXx z52NsHIm-ECT6OnXvYFn?dSQZ@;PaS&OqSIv(C!9An~C}=e2L(E(q4GKTBb~M2Sya}UjnD5|DY&VE6d!27&`Ep;R zAA_%2brK&NMQ_96dsV}GN1U4E$DmmOywg%XXk9>>*!1+dtv~KSNdLwr6(u*hHHWVf zvVRQle?8*-deJ~&y=R6sU+)eor?Y-~OsW>2ks|+jkFP2fa0_<6*Rn;{#;d*h;+-LQ z8>n_ORNC|ym!l`@Y+?*IdwM56qrpfYr z`dFzt_14D0;TQ;;sCh1uM-4mS2XI&PcohPuqHrI*j)cqY%e1h}H#hV|^#PUEK_xTt za>sXr)c9I1g{K{BZCnNA$r|Ll3M2cZOjGL5)6N4BaoZy{>SYDHa*xoMgiDKV?DoVC zg#&SqS3^9xE2M;kUHe9Pigd@ZcogXm*T@pz!1pX=k=94Ea;rIXVH_-YhAeLh#OHxg z%9<^+po~UxY@2*{$Di?agz>pcdCMz}s=}#Rybm*sF0A=W6MgIKvXX04O)`An8I>ST zt0+REm%<@Ol>PrK5tnjdU)i2$yFF%bw-DFFNR7uodq;$947Tns79fYGF?W!t2`%N2 zA*ZB~WLxYOAa#@WC@El3k(+%8EW`leuFX}c(lYL-;p2R~noIP0XBC>9Z6#?!{H<+} z0~j1IeopG6q2l>{U@;0|Y0No3LpL>BUx&s0Q&`DB!Feo?MLGJJIe8C}bCM`7d2#-6 zlxO^FAqpi;#JjY4T9?kz$0Oo(deNlvVI!>s$&_L1ao+`vv{WQws{J@9O%VZ%E^|vB zQrT6Qp?h^znUbBqV1az-iinegt(rr+h*p^Rki$6bejkf*idxXr4NJlmXp|`5TOJmt82eLSf{~i$ ze-*4J88Wmy#tb82$C5^g6gRILD|fniATb{F)O+wlrW2AyyclLBx(@L6bTqeGjF?4_ zdI|E<`l?V96sHu#Q1S$g*s7p5K*51hR@;yQQ989_7khb|(Hkj)g$k3{C_g*hVjh!t ztA~K1h5(^Wcid}b(NJiP!zCeGM%mp-6R+0vW!Ud(KR<4o5?CEETA6|1aE{AR!|v@j z?~_mxMpb}{M85$jmX$lMHZJw?JwMOl|h=k*C$ zhDYyr>}qstZ8*T-k+xxq+B*A3{KR1#*{QBzLXnY?RYK{ zt&`}VGm9*?(5`a%-h%d+RXBtR-FBTu@a7j0Zdxh78$vua%pqo6hG8r82x*2SUZYsQ zWF}yAU*aSkO5ud##HQt4WB6r8?2JwNrVPU%B7FR#fkl+~>euOlJQ$Q}cw|ZBDrT_% zTZt=`8MqO3>hv@OwRG3NOMrh{@^1>(B~rS0DWo@s-M^_N{5FF5KTLrCQA67!wa-tn zgsvICdYme1uu-J{JBh2NI=Cp`5E6x84TSyujhHETrp{aNIP$~!do$JBK5!1`pr1ag z7Vf{z&9uD_mOXxMwV-yj+qw74-S=xZNT&-K+;O0dwwcsgCS|AW_uw4l&Ucl)Nb(#P zzCsuocQ^tmF1gFJTTrb`h_jPrIz)T=*IcLBUuH;{dB>mb$4TDLa+*kagS+ue@Bg^K zUol+o*=NgWq{p$pV59j-9-t0rD^k2eKF70M&s$X0))TcxYcf}pP#v567qT5vDTgynZvDWzhw9@BS%?PVJ|H^ifYbFap|`n_9U zz4-A*Mo4v9A}FMkJLKEbF(x^jvW?ly2pcN|bfrgGc1`X^+Oeh1PiLH|p3z zn`fZW?~t2OkSX@di&SO(I)~_iRZ_z z&2*cZkGosl*$N$DqjP|Gr&Dv`HH~b7BJx-i4ubtSzUeqV1!e^jpB&@vP%g&?rD(=2 z*n}rz<&Y35op7*jSFYy)=Puc3e{kc(i2SpPVPoh?w3S$jw_j?cW_-6F=lLGO+2NF- zZEUwR6dUI1{~mz_^DP{+f~TLC zU3Ga~DuLY=q})tU*GOC@LtZ|{W8n-bC=_719w=dAsYSu{bq$&f& zRTbjta&5=M?Q(M}Bm% zw`%6STl4C@nfm^GwYqn&?yCK>do7+0BwzUCD`9Gp!bxpP2%|eW_?;UQYp9w4$6GKGKp;kSh;N@11Dt2lI>rh}R2D@17Ek>Vtt~ zx=dgr=_UB*91Ao;0X!ULKR|wRo#J5|gC9smAI|msw7JANrC%9UkY)M*DtL-(Pw2`0 z8Fr~QYOV5XjN#e%j{io0zl}wgo-9Y&tf?Zs;rYL3{Ik9Lc!f2eMEvgeqe}n3{Q}RY zOGxfNE5xTP3Tvv(q&m(sdW=@@m-tEGHAqb0U!Z>_;ryxA6H8@Lv!SEXQ`A0^)frY( z&vP=m++%IWf=EW=*Ql_1Rrx}To!>hJFN)viOItaHkL|7=xNGj$O&6M|D(tC&=(q|` zG0rGO;cC){Qy0OyK)OvOC4wtx8;@GdA;;JCT4D zKS~~_&TH(h>)x)HVlO+?9V(jpw5UD^fF~5=iZjZ(GK|Qtp(wIH+@(II2<&2=-(2t9 zZ|pJ@uUnvP^4_SAz_HZT-d@_hw!Gd>ak)3U6#`?Ur61fI?lX<)q$0zsD+9X^xtN=I zDv%x1u*3B&lC8lTWUI^xIE<$ZhNDqMudPA9B7q7gV@N=N;db}F61l&&)%p14X6Yr4 zs3ywsXsp*3RLn)aRIJV@qqR;UeTV$|zSG!eonV)Cg==&P3gm1Zpc}_{6@`;R5vf9m9aFX*i*6uWsaCL zreyvH*?U?}qXFR^T$+PYMwA$NGspgu%w;GiW#&~|egiZZ)iaeWKxrfYR*FCrQW}g? z#l8P_)_<>5c2y1{m{!>4gSnnwF@ww8+dJw}S!ki&p~CTx-5`Sh66+Dl=bKt2%TXxp zg2Ac}+y>}D#eE%j<%fsv#W&uR0cszP+A0wW1 zMFX+4a*gclnw20n`|gLlTbCO!4>J;|^4flXJf?n-8xurYFnJ|OE@NC;f8N}&F|b~B=CwVSL|ih($u9qF{`7#F7h;E??{Z09?>HThvWI`? z9dbVXeD6V;GmoK58gT7jO zB7SanfKN#xkZ@>nR_1q3dUxT0%Ed)WX(ZI9W4c_viy!HY0Nq%TPHxOCPx8TkAQr=u zKp!o34qXmivzk6*juPjQ#=x3lBCJ_y+s7M!BHmPQuH!J_n0STy(U*3tW9;LrHe~i= zV|x|EuN*->h$ybC_%)_L(jsN8rp)a5eYd4DRu-T0kj%`5x5$`yZQ0H!2&$5l>rL3> zZ!h!im0S)A8;`N?J1{gS?f)zXY)OtjUNU||8 z6T&)~KlfvY%-87d1WC7>-M^c|06v56{DeP^s;3o<=3D)DghmoR*Kc>2PV+uiZV_s{$>+H=pCiZJ;Uht zfTmPSQ(XyDHv&v;h4bDjde`;g_5KSSW0;<6-RfP)NQN^Qj{7txl4$&E68fvfeXCiW0a9sPnUyN6A*z%!D5lY z1^Q?V%-KCiIR)oy2v1EsAzWA&(l;)QdxF^~h(Med^p|#afV?gD}6T9_-1cGRC&{#xc|1JglGL|UJgUYEq(im%6 zlK$sP4P#(^F54+`34;Z2Mp8aC143JM6eAnbPRb{m8Rjao+ux!UG+!RV{bcf^P819J zQOvBC^5E)YRpi9PT5O0qBPXsYm8@*I_jS>#hFNUb?n_3lc1W9hgcjmY6M%9!Iew7) zLyQuAq*Y|(wT0PQtDQM5&mre8SC{sUa_4*fYPwSU|JrT&dEv5M~pE*8lj948@ zj}*5h^o)nQ4J74w*Rm3vEH8apZQC$^r?z=CWE9xc@eqetq4m-OGB)=)UCM4-SF?)Z zdK%ebH!*&;Fx&paX7uKwzX^bwF5XjGmuhsYPz#1Wo<&MvcFMJ;WXfXt87ZR z(!W}thMyLrl771>M0O1Vf0+4h*Lz+hCkmzYx!pu3{2J(Jzm9yfvS)_F5K@gRkD{7@ z7W<+x3STmPWHUP}E~lLWljaNEmL@4n0?O4Wap!T`E|~fRG)+OhdIK< z_I7cm&w{HIJ*@Doc94FwQr;V@9XIYN4*!#;wn~4b)_t{oD*T{w%*DW_&qH-#qy1~h zfr5+voOQ`d#@ebP=UhOw=%K12?ZF-lb?`v314ssdr!qeL2N!^CEL!Qm~ zY@|Ut`U*WHeoUXnY=}40^{^ZQ^@7Ho_?KQ0Ij+206y57@$x8)1Rom++$|~&dbp{a@ zd72umIggV1CSSj5eNo~DeZIJk2fbl->|SOuU)!-8>0gR&q*ka34odlM`{^uY+}{Bu zGwpiHzRk!>>Rz3O<}=($vu0m8QFx)8a5w3yhU3Qyc+-fvpj*sz$|i9mGRsocYCXLJ zI*Gi9kMaD)oJUZAS(}8o?YT3(inUtf>zyP857anaOIC-izAah86>0DqY>i%TVd5K! z5Nz1&M-~l4=g-=RLL>J+rwH$Zt!76?B*G1CTy-IS)-et@8)h%BILc~++}#xx(Bm&3 z5@z8Ky2Pi;vcORU#zUA#3Mt_#dG6X4=`+fy7Prss;^r#>Sj-7)xSCbJ`^M4=0(24S z>8oJ-<1WfyYIlUdcNPURu? zqEmL#L8}_DMMa~ZVr;1RbS*!+$y9?XN#f48jBO=BVZ8QMCz^o`@#8Xnv#UC@yP~@@ zTGDLPPrw)s?(Aa!MO-P_>X7gD)E-`yqUE`J1(0HLKNJEbV;gdMld9MfdjQDzt5X0t z@u);0<()DGl_!BFr1Kzx)qG)WwLWZ+(RtmsUzsSIy{USDUX)*84{~A643Wjw>?}LJJMf9i+2NSd5fRT_ ztPH<l)r@Q!aF4kffP2UL;-)-2o{<+FW#9!v;e=sin@CDx6EBdb zii--743|NCHJ09Quw7uvJ3)*5TXi%?0GT9#Tgzs9U{~|2_I~*30Qq^jDyFW8>)T(s zDJWsH!A$bJ$&(_cMs5`KdOnKsyA@7K*gG;YG+=FM;&>4`o9T+d96I z=T~<=*Be*&51CI0j!4gV4lh?j7398VTO9B0KDgx9Nd%YtVTDKD@08AFUI1SH57V?Z zXcEa9+r%s6l1Px%NWIZDDugiES`{NCej5E%svNzL2(vB+iQ5ILB=FQAo3?{ z)WdtTKX!u6$#>}|x{de#+Rt@?vh@X;eh_@Ey7!Mo77ixch&oP)NulNSJ{&orno|x$ zz-!ol%Qs0y4xbQ24`yAlShKwi@9Xbe}Ey z98s9`KRh1i#F<3LvWJdt!ScZJ)KQwA2I%4v^8&2`wPWqFA%>~%ExXmMWZcL;Zv0bUtu(ddh7!SGkciGgONWu&%1 zD)=+dJ0)M&w3+w*{_emm3~(Pt^z^F|KwEi-?kQHx(r zhg3)S#RlzbeT5KDd7)-BUv@SRz-FD--JKD+S1yk7a%UgR%3x8XaDPGNsev>A5@5Y2aeMf5bHTHF#O3fw%Z#^RpG4 zg=fe7n_$EFGpLA!4#E;5zb7|#3Hl?ze_NzIY~2`7@!vl9pCuB7Pqy-*82@Xhj@t7?>wpx*<&-zZx^Lhs(sPm7GlsCNOvMlw0(T*5l{M z1;hK8Z?mSeKx9N`+3mv_PW=rzmC?HhF2*xv>Pf~IQ_I(EZ5tyAv zJrLt~Y7{`lE{qN_n|=0$^hDt=T7flEliaQk{2@R(;Knlj=)G5yGXbtF)f3~oEGlmf z2|T@8U=`t+ePG9B(mt*$n*gg_Hpj}S(nZ-VHbyC9%Jk2hA9O~Axl!j#cli}WIWe1BP|`7E**(@ZrPoGpGP^)f(kbL8 zLf4)paKV_#Yg-`n+{L>$!TaD^U0VVJLz)IS1CgoOwqvy%S^ Wun`nKo=rT#x^_Jx8qP&f!~7QjsnnPN delta 109847 zcmV)KK)S!u^#VH4~`|8zCAOGh+9{;tNJ%9Z5)BpRb9dADS=fmHB ze|`M?{7D$^|7!mI`(O88AE$pm{NtaifByZjc>d)6;rVYK|Ml(L_Rk)ti-&*w_vU~6 zWBTpy=OaJ;_Nx79duaaG^OvUIw%=VJ{QIx3^KV}tzs`4m@@4vMeS9(d{_X6`&mVsO z^ZB!Xd-w$iM{Kx5`mm~jYd#*pfe|`M>AD{pF{;%~_f7*}Z+w@-#|CoQ9{&RhepPIwu z9v+`(^K!?2`fc~&|G%35@%_`MfBgCVpU;>1{&+I3>3`Axc>bS@e|`J=>!07g&cFV> zI{Xluxqp4RQkINk0kuAGTSJo7K4CKO2WzdU}feNn%e8@+ItfYBD@fU(pAuGkl=N zCm-%69)Ek#_BZZ}{Aq8fqicWe#@m06MP)= zVowP^j#-g$q@R#!p>YJ!hSiHUtel=OId{G=pyvI z4CxjjUf`^cni#1wLX^zCz#_CHCCl>?14-vWvr_jX)Lw*^;%sb^B^y(( zLVv3nCm$QFe*|?-c5)U*UUvSy(o52`Vs5sbyV0X(6N4)sy*V=qU*7_KBz`yxFB(7j z-W5H0ySW=ZdHTq!H3+??BIxzNq>I0H1^PLf27OrocvUZh`KX{7RQ&s=ZKPg zOv{Whz#|MsD+4JUnsw01@Ls(#yjQIZy9e$iM2W@g29eX#^HS_&Wq3cxmErhDLIV7e zF4yi%Yac^f83H~&!OB1iS7$x6GJI683?I#6hV#|oWHZvP5UbI8ffz5{ED|3FAAdf? z7mABt&3zaVoCgthi$$+PRo<&BoA3FRO+pmW93N0c5l`cllR~Kb7@~=mCh!=dIXPTE zh6tBa*w;hRech(tommrWU$o;iG<8Kr#!XsA1JSJ!ch9vd)96Oe~Nk zBAHkWAc6=3SSYL`h&Q^T*`1oMbSI!Ir9z0K0*^Mcc!?pJr4xRW?;v8&Z(;-yXFpZ# zm0`~XijC3bfY*h1 zMf2>3s=qFTF}Qm9Ul%MZn(s*K0+L*4*M*N}>q0zgu&Zn?*QJZ)Xys&;_&E4g0*fFV z6)w;yA`D=ykVX-2axjq>N={xwFLSDUvIH$z3^-wtfrK}&*$k1b0cpW{Q?oT7Ef}xk zlhGSzLcm@q-#GdUr|v%zw0|Fw_tzimgR4}c1eJ4rP&wyCmCAfl*kZ)&Fk1XbZ63M? zqqo*v?)78$sDJFHb9RT3`q_(|h}&m$rO#lJA|ZGVmCWJwb2v444uAQNp~)jCeEhEX z(KCANdW*zzs(kF2Pn}73rLeiOYL;2|sS~$WzO|+}Zl1bF?Nc{nK6d*0V(dh`$B(0F z@B==A8d-z4)|9%nYjhBF4NX7B_GpS~-8@~oe_H(F?EW&thnbzDqjT!bnv*Z0^R zrf&4uDXBldtB?pTDb2beppdB9rG(Nj3Rxjq>TA!#NLHH+Tev zkKYwPdWy&HT@E#ug_8Nm=Pa<8k6PlR6NUqg_Bg9aXi~5qpK_6en5+bdB=ltTx*15M zER+%{n%4~=6IyjC@mrg-y>9P^_`0FgL3&qGwbxDA@$)W+?tjQa$=|iw-4On+MRVXv zNZ!ry?tL8kR+psiI(`R7`p&y|c_cj+%59Iy(@XDqM(lZsELb*PpZ9%_Eegia1S|5+ zMMQL#N(r5Jd9*SXs!mtM`-U~T+Vr^!+FUceZ+Am{-_lL4E7$Ma2=zx2yl*BV6bogX zseIqyFw<1sZhze;=7hukak%eZD$ex3=Z*BzaqsLE9u{ha2R=QWC=z9%5haj_%9G;x z-u4=l?t@B7`bh0UZ2XDw30^m~Y{Eh>k(MJaEOkw9#C1n$epcxym}vp{^}(ql%4N+N`J1#FFA_lbA8cV(v!ujoNMa} z>*~3$F0Ky-?2bv1E-rB;-S9a#s8&DftJPdWdUN?yr;2DsU1Lv&UrE%tla15)-uGjF z#suM@`}&@6sReH&R_ySTBP5Wt(Q?k{Iq3VQ^y zH^YAz$aBvR=W{&wuW8Rc8r017L5*F`iZAlIpys%QCS%2Adged=2}ODdAWuMVA9W%emCqVy>L*|2HK?9k_()(gvj}V~a#uXCG2%~d+p`q9vsW3|pwD|cuu(kodw(q6 z)R*e{hfqEA&2O*h;%d#g>_v0wm#j5zxj5y~b(zn3@;srWG7`duslXe~2MIz(5(^@1^cw0PbHv zhxfJ2-3!rshY#(G+`93w@=@*`qJK)WAI#@cgGrmHe=0GQK)NdrC4VCPX}K@=1K|Ui zm-%_p&&jOZa}siSu=4ImunKogoW_sCFIl+Dh<2OE2aTm2IS{2b0>|X!d9EtjZNQHk ziPQOG8Hu+z+Cqqj7L_FEHlqt3qTOh&use|WdH*Og#1J1~GGF+;L^v6}e}92;_liWj zAvU<=ox9FxHyu2D@)LMJ_zAr4y~K8NFJbr0OV~ZUXt&dPF}^^%YH)YS>K4Yq-D7z- zvv7C!kv7~tKhO1qyFggmz@Iw+cemvf0A8yP?nb5X0%2?g!rkRzH;Y2!4#3^zZkLM= zFw_orm%A+|0^XHnE4ta3IK@Ka^oPOrmq@pFd9ooA>hZ zH7$QL8H)med4WNO_z-E~!K~O_4A4Ccxum~YcQC~7pV=m1Qvqz%C4ay z8?}==RKt_7JW1!esy(8C3C&1daHIpU4Qqpw_`v)10S9|TG~xHOM<7dZaIr^(_&ua; zVios@AhsWi>=An0ytM(&`9jJm^t{=Ep4JHZ(e>3RdgNT!vq>Dyo{pxE_E2`+O=6u! zMCSC8MXWp|5&?p(eSakq=sp%4Y!YBH7W{4!24nJXm!j<^qJr% zc#b|pUf@$1eWKMnN%U!8t*eecKNiG35rxnueh`X8EnQ6GSm;_xeI*eaEgF!1yfQd6 zDOK8Fe}V_6B$L$vz@x|w@Dr$tVNyAu=oKQ>h2CXh(tinrCHC<{Jrv$f4#A!in6!r{ z*TR!);mNrOSQBt*a1WLS=TNENa5WL|O9Iy3!b4W6`}01B?hgSiZ4w7A(NZg0pLk69 zG+wzD7IKf3N`!@YSZPB#+z4%j%7&ttWKewM=%k%srKpZid^wH&T8!YjNEnJ}?8GpQ z=$ry|Mt>!-QUxG7hQU`CSg8;a9Ye6I2dq>P+TE~HN<&<@7OZq);p(moJ@4{{o~U15 zgB*y%qpLij=LQ#b9eUnfLFh?v>uZvS8=;M>+t70as-+($?E?KwXf$3!=(&x*M<6)m zB=p?!IU%SoaR`JJI~Sqn3#$_vKXL$=Ru};4>3<~j47xVkS!Hb2H%T;DhMpwX!j%8G zGW7gdBJ@P6^bO%)gE%E-2}M^g&RI`JJxkfb(T`UXjwXxr4f#)+q?hAh!&4X?9We^O z{t!|NEYV+xY7uxlDAM=%p&p80CvL*j7B9D_D)9(!!7Wl6oa3Zn5TUAPoHRp`{*6v{ z<$umXuKX^GTzLvA1p(0}b!3%?Mf!r3HcQuv=)S>Hp&~j1EKPu+4ej{K2W1r3Iv>Y$ z+efgqqz|fSQh*#{!s0ww2azZ$4j!9TK;{%2QAJgV=om&{9Z*HZnCKY7UETIHh1^n3 zKNeUD#>n1DqNZT!TT=Ad!Fo;x(dUOE(SPT;o)f6r)TEB&(Q{gjm)_lYK3q-g8CP>k zs5LdT<4)9^*sgB~h5BL4z{-*8e&TuV#O^k_}<_O6c+Sb zoO35jC9S2mmg?PXaZ`gWPO!t#cDH;xo8_){-8&k8H}kTSb-+%R(UO@r#D4$`FgP&V z3|0J&ek7w!rCsST%{F^${m$REo$OQPF2>!&lI`rx>Goi~iJf$YD>t!hUFL(>#8{o? zN84WgO)Sb)+%+dOPaZb05FcRh;}{)^>#>yCWpA5U!A(r51i0J89<`g;jJ=CpIzR(E zS+i{v$h4asw;NdOXlLiU!hc;2GHXV6HVVhR*zPuoZyqmMkc-`|hlbduhgf-s3%>U^ zFJPn6+PCWdrru+Qs zKEOa3US(2<>om<5DI^RsO$)Z0m{Nx5xAg|5ZN_;%$#TZ&#)))j$A3CX%0Nqy1viFph*=BFMSixOPDQEKA zhS+GZi_HbwSc=+s*vc|c8_&mKxTRewwQ-s1w@htq9;l5=sPS#PdpMvrE}=%W{SG|w zy~PFZ8{8lgwcvS!Gk>HuDpeDH+jOAmx41b|Hi5FcW!u>-ceU%@(FD7hpPj4&b~3%a zPiH4zHHkLR_bzs^I>M7>n&fUhXxVzuCRsYETZ>le8LO9?n5nHl$d;)!F{^TAU$u`+ z?V)0CwIEYFcMNw?465OxRw50W`A>s}nqy3|bO^T=Ey9o7{(mXz0=?~{%D6}aZfXX7 zRoA{)M@+?deO8VZ?cjaMR+$~mYIY5~Cs`XW+7>;ptx41COt@;(SM0p2we?DUTcu?y zwBL~0J>(JG^m2y5Ho+(=)00fSoUOi@b1vxSq=nP#&+>)R&57xoO=stIhtSPxEC`=T zK4bU(#ZKnF6@P7J{uOP8Di<@!)T7v{n-m{~J&MHjdE3sBbB#Wiq_^u=y!N#kb7H6T zEjq%eov+wpz35r(zQt>TJ8Zh{T7BEyT+nxyE+A%ItgRR7+afJnp#3e*ip8a>$Z&8( zQoI!q4Z6u%n|3lf7LB-mUQ!j0;%W1~va@lhDT~1Qu778BPTF!p)J3vg=e>Wqg}HA{ zjT;tS5R2A#Nwqd@QjfyLIn5y`SpN()zLSF7_78Pk4N=c}wBzP9dX`C#D;_#v+| zVBFN2OnAzJ=%0Xtsl-kmufdw)b@hlA1ekY1poiN^GLyj z{~xcHW8nZApCHi$!%?z8q|jn^jYqpaY&RJ@a)>3WM++UKD-{m1QgKz_KK7lKpz|~@ zw5g(_2WJd~cr8I=t75c}A~@%O1tvu*ZDWJtt0Y=bcJY0*aIXBA*SRtdBwPjxL@41b zSbvCc(F~!4(bMkyOXFA}SDf&15OIRgEfYZ$fFOW_C`97~SBOH=4D<5yT@=v8ouEgQ z1-e9xu*Mb^qdcsO#g9$A*=x|2Dqn?q#7Mxfy^xGKJ z1U@k#FDYSjy=4x8xN9aTq>BJ(xF8@Z=tv3ZWyv zj7DL1 zg;z!+H(r$l3vYBzGo3*PEvRPVNNfl5X7?l~@)31w925~>$iLX^MQ;ITu5yMMSle}>TD z6hO$K(3&MoxEn^802T)b%B)Zzk%$HGXab}!dJ7ZQ|2{z(XY&N5snOZB`>#0(6((b( zG7?~n0c`IGE<%MqHF|KyAmOp|9xCwKB7^r(fy=}+4i(<%)O$Z}Lxq{L+%OYp_tJEG zIn{>sna8{?he)B{EK*o)n|~pfYx1JGMDTSM8`u#-DmtKq0P-dWy3%8e_Hcsx0O9WH0t7|1Aps${t2PJ%gcb`BdVgO50vAGP2Mr;# z0tlh^4H{5FWKWgrfBZ0g{lQBjlwb9CnLA)EnMkVZA|x zCJYHl*yWaBRRN+VOkiq)t9pY{WN47o1eK7~;OY%wfGC0kS*5n61q;cF!&F&uC_-9H z=qfb?PGf|dkdL;wYJZ;A)rJhcklE;qAFqhMNRU-($PCA!@DdpG#U7(Cw%+KA678fO z2jvG)ap;M{P)Ma^U^K?Wt{ZN$N>HrO>D(*t*3|~B)6VlrmUQn*s&D1^Qm8pza!44i z!NxvEHHR#y!oKFv=!*`^%(j@miw;YPB_@@xX*uTr38Xk- z%n?<($p5HChtsStlS1=yr>(RctxO<=>s3`+N}`0ZR8=V%1(0C}C#p)zcyih-;|b_4 z4(QM*!H=pE1!EtJ@O&qcLd$k@X#F=jq>)hS~NuH9fb%uC?GLm_6j7ZO|u6X z8ic`B9qL<1zoCT&9Y#rbw4fx=dmXR>08nSVjM{HiM+BDdI3wJ_0LiWZ0o0Cnn;&ZS8CtV;svzw zFteyU6r?cXl?SGc^l9-rU*+MY>R<*dip1CpChVhcHM|W1i;7H-8u50!c_=I-5h$!f&s-^*?Z|@cpm9 zzkL7e%lE|$j23=7J_E7taA5{@9yw?z1F&ThYpW)l2HmB*F1|}I7SUb$H_|TsFZsi6 z0saGKO!18yugd?8&ZM(x2Hd=fPtYsc;6wap{vn3=00U)sf0em|A%6ctYIy%%;_ijm zy?;v%?awlIZUyZD%__O8eJ?e{01ObXzC4!raaZw>fN=K~Wri1`gTwFKxBNp}Lh(nl zBmppglp10H28iFm-%AWH0QWB)kA5vPybv86e&@cRLu=8Yb=+Myb)P<$7!0jK`Ki=> zNk2UgC4Zuz$nnj7p!7YNmwQrzB6l`@PJc9PJHwww$dP-SD^qQ?sp?#xtHwxkTCkaD z^Q+5S>BO}xx$A?=+!f}tX=Ptb^9aCvS6TM#idYJZSJVQbrVd@q9LlP$oJYe|7a+NrK+0Y2OiGJtrbf>wJ0p2RV_gMYhe z?FBd~dRBJ<4k{pQS_#U*U@*x7Op>OW&8KJp+*;IX)XWZu?x8K(oS*jWYTO{0c)IHf zgj2!`vSQACnJ^AuEs$vS^HM`ZxEOWUVWgNt*vF>T$~o}h((CZM96&^Cv=Ez6@=pZf zO;=>#nvttYqGQHs%dgP$%8@dqtA95Ea^#V%NhM`EU;k^NWeDPV z+LZ;krXJTzo$mS~7NoBZ$M-y}EC3TK3;>@JuPndh%ZzN67L$JERI{`X?%k#m4-|!(bzk#U%1i}<)lVtfDxzYPMd1Ia@wHyeO z;)B6v|IRCuLM{Q607A%q$$9#s%!m?~JN#+{HofoXmf~1U%ZM8x6b4AzD4^PhW^1z+ zig|CreR!Xn`;ao4O>|8cS$~{iYQ&2@jCY#Fd*7?Gj%LBImL+vb4{dd3TF14RbzD!k zj+>U*cMyKV`EIn^b2?2CuuhsUB0L(PqJ$UK(h+SoPwgQc~YcN9#%E)_~2h@y>_t2UIJ)_Yz%6Cv$af^ zb6#SMGObM$wgA6Zd@pR=x!sv|Zd=-(L~;THSlt3u&zlnfSh@g5&&!t-;O8E(^I<0i zC}yt2%jsD8ACp}m;Waf#3Vt1Gr67OW;h6!|ESuGHa`QA^9Y4`)_qCVLm!7kmUrE^w z9+~e$6ig#svxOMLtItN;ki~lOjDL25+Q=`2X{hNVv0Ae(BaRTubtvnn9-`BzOTtcL8Ml$^)krFx9fT7F`&{b#8`f<&>z0RO5evK1(I z@ZYN`Y=!jQ`?Fl1JE#+v+#tVKY{uUX5#RY9OLgK$6CA=FTvU?^2th9)KAIU_OB`o8QaiupN-4;zy#w zF-}mGP1Sb-RDtt~wZf3n*j!RId64+{vIUA)D4PC$W{i z&sYWid7-Od=C1<(ywKIDX*Jease1_4(sNgWe_rT+KtP3L;VBX$u`Y%s^b+`gj!3C?g>K1>JXIfocfz#@%YFqxZ zRBRnM+{0$6<0O8s$h3rQf;Il&xnHuTp=XbFST(6l%eArS3u$m z39g{^i5d+wy=qClk!onPgp?Iwy@^fW~FPpT~ur) zhD@v*+#|X!u&^U^cYwKI{74@^a&(|Bu&^0>0Ok~c8HM3u~@Zo573G})Kpw%VN>2l70_8cJ` zLxl0^C4oX&pCgd-2QojsBvD{VbA*&{kb>vPrnHl{=s5yQK49T(GED(jdXzA{KS#72c*1B?#Kv9!#FPdCfChbL!D?q=vSu#c9w6MElO$hh~6E8iJ5WDWq!gkAOzXD~Sk z{F4Hp->d77<#N#Z>Z(MT)+I~N=!-i~AgeOrXIEuH3#X$`g#>>Asbxf;`9xtl{w)ta zGXa`ffF>58X$8`x?kko|+?AdbE;zG_K0OrW>UyQ8pz@cAWJ^7%^prV&TiUe%TG-1# z0>%nknYfT~1v{BSRx&pm88qF&gpEz@V=#eZCnYGvzCvJKL6W)uqpEF%z_NlQitnQ@ zXoPg5AiY685-GGlXashLfIUKfk||IIc!WrvA|k;fy&P(8q2LiHlM0FjkMshnc1cw# zLLZzp4A1KZt-=K#NmL?#xpiU-qt3JoB~z-zd0Mhd5Xkzab}*5oYC|A`7m&pMqrx>NTkZl{s75x?5=NGf$Hk zna#ynrD@;%6#KlSon|CK%VDV*zs%L;OlYQq9yYSqB<4-EbEY1$hu^o(aE?AzoQwd9n5h$n+ z+Df>{snXtVtkRV03RGYh)PkQTs191mDQnuiC2}~-)f~yhZ{mJH3?Fs zU73j}>`zOP{>U%vzey&ZfP^6)$;4d)Cu!Hf-%A)3AhI2xT>+{qk`~zeTFZc81a&!q z77Qbb{Na~sf`M?cFQ|%uwK{;%@@!lrFrR4lVxdm#j#IZhs@c3jhB0F8=fjVyk`&wz z=gB2htr(_%A6F!7CkesLrcEMlH|fSvvBqKsUDO_&^OIltb7kzkiNw?=g51_d-~`-?oPGC_7ERppbYSS5Kb*~_ntg6 z_ntfhb-QV0l4EEO{n|!%p^n-uTy*1+yUay561f+3(Y1`+iI=#p=pAv>#UgksH(fA- zcM-j}%;>#$9lbB(VD1tTI@h(N8 zX%sAfo%U2edMCN$){fqjwzs$FJ$r0CbDv-QFwr|n0;`$N(EbC9@O`fDKZy5hN93^5 zeFx`cnU_;H2J*nvMgTjb6~gJS4s}P=t$>&v6usj06~xIhv>xm~%EW_F6{k42zFEe3 z`n&jc;*~5jMn%q07EM;YCmmS^WNm|F84_oIySi*HOrr_IBFhAf`V%Bd2F2p`4%E3z zQ=kjQ?Ex5Kn*UrZZm-rkY!SEnCd>Gb+ZBDotxRPJZ5v*!19ywCU8}GZ3($#Cds3%1 zYt)`opO$d?8&@WwM=OciSvs_iqW0D*YVYeu?INxWBKC6l1qa2I0fk#h^Z8O+6^dkW%_uBYCGHx)zui&RKm2KhDVke zK~VI5PF~3}0g|@qKza!-A?lMX15tc`FyT-KWSO0`R|kqJBv^q`f;zq|^W@6FKsBPRfriYKM3fsqB&NZuz9DKR*asPu?JGqOS zWF+c|*vgHLniz{dI>F%fM+bJUh(BU*7kPHenc7-kvw zV~NA2s+pwA(hB~(A*;9JM}04Uc*@J-u$-}b!*NfG?N*$LwH9sUw)eJRT;?j%VX3zT zyom~W$SEEdC!vDV?YBK_zdhR-VniXZ&r2oyu#+CvHwzS{C@rGjNZZbu|q$N^}DoomcQ6AjJbHxdg zEB5=NarBmwEzZkOfP2)W3GFh2t!^itH{#TeOHLa-Y{bFRzlJ4*E_MvK5T_c zr;}kZE}cwiGMx-YdBd9-Rh%NaLL zCum_7Vj4smF9!QZq*DZ~HcrDz5w!NauDTmA!0kRMc6TuI8!C1CC2k}nAXXZq>8};xe~kxTu`>ezWD35@cY%9d$q?aFMFEfzlKg=pAR}r|)j)$rBVw;37qK29_&NfZ$Druz=vJn+l2!FC zSsJdTOl#ABie?e&8P#Z^17f98s1v-nLTN#DQZF6gj%&8~VM9eyyz|>}sD}VW`ziYk z^$_yDKv|Wfs#>UK8@Qn!q>%4F)C*_;8$b^OC!yX@YPMmRp`Ko*U{Ez%BXcR6qOH2y z0@CE8tNuWWKV{W^5c!>)-4^GUxOH2cS4s_4eQ-s8AKxWgcDKa{#)S?ut{dC+SjiSI z%r<}`y@Qf1XhWLlwg|8r*8sXLAdA_6b&%Xjwz}AE2;CN*V>@NZc9NlycK-a2iSv+2 z$rkUX6_sq!ZrYM1+d}1952e~qUQ4xt3NklVb3xs`gc2DqjV=Xu+U2a~&?2_#$a_ps|XdQ2!aO z_!ZWDE~NSFrP}w&quz76(r2dgoT~Iu5TS=MH6+jr2-cN69ECiDTto|i&rv{T2Duys zKWdqP{`9sl-IyE5zNH!g8QtU?IGaz#dJWr6}|H zvu&4sTP{mHiihYk#qDjA1Pdm;V^bqZPaBUZC{&co z3xB790~w9OqYIXO+tDOL+tJLX05R8t3Q%jg!U8(pT3*5Wxq$8SRr3^*(m{!;gOSxU z)=Ey*hI}ZcIEcGG^F$E1IWU@le9*7cT1^%UO62(-~f~XflL|Sm8Wr7q^VAYb*naXYK zhGq1kFp=dOVSZiU(X?JmT@gE2=L3{_b1PgIA9cX>S-gnWiP;o$=i=M+za6bn6P+Ha6LRx>CwEHcRWuO{6YDF_E{d7}&-VTUI<6i*0*!f#MJ@ zW-`L95VD$#PeZP@+=`f7e^9x;+~qhbNRQ~0A&6K zD^d4a)@p>(g634&@QciU!q_S7s2ch zxDgG7QB2fLXouLQ1+FXZh-zj~&p{Tm5d(Zyog0hMJ!6j?P&|ZoDLS+dg*uTN>udmK z9fLKRc#}z?&d4#=CbR?4JGOHyS%9X5gKP;Q8vdoqhkg%!Lq8PVQ#s3uglXt<#f8M) zdfbf_ma;<~Sv|_A5?PQW$9K6@}epT0nb~p@8;i)}(FX`;-=T zq4#0MJGNngm%RO0P+N5dp%?0&rn;n8z7JlX+kc>GX;pg|BJNH$axuaT+Ej%rf(g)}@Al)kuz zM+Hisy@rQdVNkdAU0lP%l-h@BcsSouN3hY=e$u_;Efo;Y?AhX(t#|QEn(xNobS`+6 zYSS&XGiiS`V#y13RJze8eMhAledgU!3mAR+6VG(8lgbQ#K4m+p#NgAzPP*ABo`8KN zZr-p0dmkr9v-WI+*>jCBTmMFwL1ts&cPHhkQycE225uD#Gl5CvPKwDzak$7Nxv1cSl9q#yO}0YI^H5bX0ts!vtY1iekeL);cQU3986Z5lv7_ zIx5=s>}n9o`IEhwiK0N|82EWGf|qHi^zl$-zNRkUij}80)qfdm;Cx zP4|9q*TEpD0;jjqcNxgNkI(;D7z8{DQ&Y6>HOlj)r>I!pYmD!kH&snEqvmM0;asx( zzWc>T3QOpNMfkw7R5slQ3-f~g>+Or(1iWkRkHDoK)ZHJs(D6dG_eTI{7SZ1yxzO`U zpI7ds3~iXV%gPK3NlN%CiipLmzK zqL_(PL?;M%8A$Y}(4leHfqdM;( z=i>IXCu3GsqjearMTRkxUg_m=9W`0ROjZDY6eMsj(Rm>W-51k&$t`$Kyvz+~y5#kn z22Dwz2@}9~6f_|s@#qZ=S^@mJLh&O6)BwcdNe;8`hXNFhAGr#e#7b~;_aVv5 zJrp+Jpy?M`(XK_Sb_;dXv~~4<)wG4Jy)kC$W!K~R#ZT!X!bl0`c4o8qHoau|2cAEac zwegg8|brGnAx%C312t^OgOCDL@%T=S(iC_+56t8#Twvd7-5Sk z+zeB=#k4?s=`A!#8CILy*|3{n+=M27NFz)yYJ^EQVm>$(p#TFHV$1Bk!v<5eWW(H@ zWJ5}`qIO6^u!gE1mJl4CtU?n~HL6Xx04-$%8_TFb zqc%DLia<*=e2{T>^q0D#XeAk`NG%2u#VvLjD$Q~JREy&rEma)p9V0g=V{t}*2Mz(% zvb4!`)}Am{rx9R2bYeXeKLmWMCm0r$iB^EA1!!UcnpPl9sz}rV^2PEJj(GzN!0-&q zKqRTIE_1Z9b7ruxEQISnVynV%jzIrh~DR6+(n#X_M;i&|W!;-_i3LAJOLr%i}L(mrcZ ze(yrvU%u1jp0Bwo7Je5H!*>kIc1O<gOu6%TG?#&w(4*Q~g99>N}40h~t>p#Pi;vtB6_yeeO^LJxEQli$o>~ zkM`taRE(m6Sgnf@l{X=OlN+~5!?cl_yk2GoR+TR=GlR>%XOijj=YV$aS6x1b%uGsU z-b7}mwsRO%`9a;oMqVUkQ_|!=yfS+baBC$yl##u)$rhy*X9v4d1wv?;m~43=w1l#2 zKEZY`9jW&oMcWhIs7fKUv}mmV9SRgX-}j+%1kaD8wT35p9tP=u6di08+$B4d-Yxpk z#x2@XbP8AORH&Zgik%fy&kHM_dtv_H`xc?1D0rHxol_J%Q)O!pMpqE}i<(`Hbl_YO z!z-RUl>3y5@81=yHY@{E=0_V-=DD>|huruoZPZcD0E_0DQA8{8@=~|0DqgoVGsdDC zbEpim3T;)ZxVQ>`tiQJ{aRk`f*~eCu+Iq%zf_)yhDv7?IWUI1pJASmtt=3IRf}SL< z6#}O>iDiY5y+^5p6$0~8*M$PoH0crvm8@q;Y>D6o1&peF9T^qlGWTV3ou7^m0|&!WLFLfJ9I7r#Yfd6S1wNu(&pmI*%Wr{lMq#9 zhs>BOQQ0Yf!IiSl6DRWL$*%2}4=#`y{s8V1t%cw%UenWKY`bU=`L|<#N*@Wok z`j1f+dKApYi z8iIV`Dq^iK6VP(W|sR!M;OcO1G9-g_0?d79UrN=@S0jTs;(M7-|ii=cvZipbN zTbx6G7XhvCIM78fw8xYnf__M5yb9J2M=}eU)DgN29Xty&g*hvCW)p!q2ED8T9#{wq zyya6A*XB$Db3$6#fN{V+fdjVwa6q1wa#JtI<&!IR@_A>ao*bg;$nm8q z^4Gv0AEcB6qRk914$*I(eNpTd^vVokM{JNF<9R+%>6o4mA`9 zq~YgK+Aga1>jX+GQ}O=A0!dHugOn*Gq`tTk#YtKf%XgIaOdt^v_*!!o>qVr@&p&VC z%}@o1iOtjV$2l2^xpJuY&T}Y#oQjC+X4p^yiiZjrA`;Ojq5~XIKKPORE07gK$zrsneQj}CE>9Q21&7s8NPW&9I zfwQt@l^kk&$*g%)UgN+a=Tf+1ph+%;IRs|OrA#ENbh(tJgq6cwss(bXK2t7bDw*}E zOnonaFG2uco)CZC0`&Bx9=+7Ve~Ui+E^?_5XDx;(IWhn=#tTOg|bVr-P5J@}yEP$4ro_YuN(vf8M*>lw0H53+e??*f$~U7D6Nnl_ zNQg+lM-#dg9&#vt(t{*dw4`TDOX85^>LiJ)FEJ5a%>uCcp=;dv0_K$3Cy|)&OzkV4 zLrpS?^i9_DU&hn#_e`qXK{B%q|m3p5Q!_Qd?fc;k3uPjxX3+PW6T%`aTN)Qls#>F^# z^vDVdJ3)1hBPLM~jRQM+bmBZIfij)KQlV%{Mah7mw2iCUM@pyWE*1L_8H(0Yu`eP2 z*E%oT1WS`5Hw-v4A1O)7VWes|Gs!uO3<4ox28}UbT%duPe|3md?gUu}Q{OIY*70)c zCFG1@rg4WG8`jmi`|O~vh^2KW%sNtThL~OhKkLxjiD7ly z5cL`6H8t*fe}TqbE6})W1sZofQRB`--wnr(DBQ9REbGwgz|CxQ;AZQ8g*JMWq=P~m zJGReDCLNdbv6QNTNEeQ;Xuwp2%d2Dn`tcd7831iKb=3^2XB|lFoK~Thnf$S`4v0=h z3GJ0CaXM84g*u%7tOI;#LPDLHt!5S!T4updts}}ff7wN6aRCU3jWUThH0=+Jkl3+7 z^r)?wx`_4Kf zRz-$chkesDHI(DPAlu?5u)T+p*;U?gl1KU5#n}Fmu9Upok zLU9~7e@a9MPUE(TNP<{qBCnPh+5kvbs0`Q$Gn8cZu&3p~u>N)TGGH6hRgY4k+9cVD z!^&z__z8OnEoD8Xy4AqmeM!#u=9vukRg*)^RRG@L`3iAcN) ze@Z|g7M4K7OVMyn7x01-k@CV_8IljUD_!>k<(!Hy!74 z)#6^7C5(A0%qf9pw;ktP$CZJyuyQ3Gf3LHMT&X=*KCFu$Dw$x3)e73*5if19B&@hu z5L>ZIL&&c2?`5hH zP!B((BLFG|BpUm3x_Xe4kY%DZ>2<#JGzq* zVE=mXkVWS6N16FVeVN$RPQu>)dztybG8(8}2Cj;c+GoF(nw!2+kG%JNDPzn8OD(1~ z<#DNcv5SP^-1Ly%DLyC$)y*&@f2oRNLY_qjbsNu)u40}fK*2Eloq3i75^S?o>)IG#oA3Q&n?U<0Oxgyy5`xyz#KnuYjz$4UBaND} z;O6@qxTyg*Nqpu!tOw%R^RgZafSX%J0?0;ywDuvLeFPjyIA#^F-1>94e;`x9?qb02 zp&5JR`+&h=1Yj3a{i(_R8tL*6)H!`A^?Si;&mk8W{n;59jYz`< z>YP56BFMFt$_G^4@rP0(e{5VGh&Cw@Q5)P$IB~iGH&cwn+Tdo21?m#qG$(F$YSjt@ zaPw{`#7$N!7}qbA-3A7nDYG6$tPSiGfgA|9d3OUh^~}vYdUi@335A{=T}Pq_a}%}L z0YM80us|4dxXTa0&L{B)F2K#a%)=m)C#M~D0d8hxAPU+F>BhOWf5FYHjA2_HN~-?L z!mar)Q)N=%7u@2Kl|H~tCK!_>64qyK^4NeWW&YaWW)e-#!vy^5k_i~neY&;5O#u_y z0*wCbkQE&$lCwUTo9QlPSr4@b=4N2EbNBcG7vQGS^?P1!jJVZaeQ-0Nnp$p|n+i$Z zHyOIp=Xy!rm#*|Ve-qt|`k%+F&Iu@I%=i5KGfA5|>UxgZn+w|2G4FHOuRT-SI^=t9 zsec9do}XNzd>i|sG8l^Zp2OzmOi&c|I|ta1%C>fb-+9{9T-n~9_B~HIx~p0hGlMMb zCU-?^wz|Px!y+oxF3a}@)U?fF3R$QTp{hO8NOTuX88Jc+f7|Kfi6gFyF{mhD^Vdp( z#Q~8E4uFg?nt(s0GIOe6GQH68rHuP!l=srWpGR3QD~RNSm;iOCMNo&D_H@U7KVneC zOuH^lg@r%wV{F&>$47YG1V!_(qv(lK_plfU(?boWlnz<2`_z5wtgJ0q(>|NmyfiSC zx@pkuyqauYf1*oR3_@w74ZDsBLQUeo60o=iXiZM5#81o2D#Yg$U><4*8x@0Vpae{^ zXakvb$S_Tu zF{1nDf{X<8oL(&JNav zkwTo38331(0B|Y;;#0D4-(vOlB@L)8IZ{RZmBY@9!I)*$lKpr?*QuE6&hL4bpFTV|)zp zhO^t2V>iHKR_-647m0BxDIOA^OUYbCp~iTBYypQVN3q3lO*|=$e8V*HOu2gwjX#F0 zNZ!FeKE|n)nX1Mg>tVK4i?gjfbI5v#71v^{I0^7a>O!1Nn*{PVvq1i)Wa#fe|3C?3 zMCfme>tO^k7W9_{?3B=70&gq={k@!a8RKzn#jEttU*t#5%oFnt{f$5yN{}Zefc{2* zfCuK%6#63qbjHvh35j$L{h`A9EEWF|s9Lk)tKZN9H2K{wv0kWCis`LF2TSqYkGFU6DiOY+B@l?k^ z*|Uq!SNT-NVx!ysxtY>f;TF;N{#k!RY7tE-jivOzQ2l+WvRFd+4AJBFV)_@=zk=Hq zUnU*@9Bx|N++^Tv(3h4#!{UxSbt(zVH>>F4i%_o25c2KWvJoG}cDL4Z1g z0f8kaRSB+U^Dorj3-|E$a2GLfBjAL%St1$nu3tEL{}vT(Jx5UM6?`OW?FlDzBJ)rZ z6YE=<=C>Y7=vq8+Bd2- zF`>n5eW7n@%__`bKtYixQSwkRO#-;nl)5TJl( zrWJKjKyE*43-q&p_E|X{l3OEA0Klv!&=$J!O7X=LC z6(5vhA}!d7S`O^Bhm7i5*LIjGY=_#{b}3p66)incS({+eZ5Yt%!Mb(=$rZq}<|5Ac zxsV~WaWuNGj{*i9x@*YjmJ9_937ikZvuOT7wNO9^Ee#-lh6B5*k}0n|L8IMk^tL+II)mTS;Ag}1YmHD0tO7~1Iuhk^^)>V6+;0* z2(yo)fISL%+Ms~1N!}mLe1^e*Qv(=qpynT-b1MS~WWs>Jh2w<4j5e_s7O}?g-wpcv zA>2O_9;`Bd5}?i+RzL#u-v^B3tk|(QdV=-=!x?ac99wzl1J3UL0h3_Br!tJ;BP7VQ z0QA@MlDHQ69Q@ecC!;R(m&?-xI50v~uwlGDebhxCFk%}MfV%~M4t)&W!GPQ${FsYA zV1a)Gz{oC6?f9(zW zyFq_+fQ;gLfmnPX#vrw!Kfvar#XiPeP$babJDKfoi~9b~pa0SM{(e{!0ADrjbgn@E z(&Rrjng-DG&M+>O6)@-s5_R)vPP|28*kZId`N&`7@`zcx@aZ(qiGeYzV3AJ<@GC-K zVQsH}kgx85LLN6z1*V}Ky>mXDrEPG+KLqvqpmY%9P0gs7AhnbDOPPc^+5)_j3yvqx zJ$R(WPc}m2m=Wj#Ie6TTEKYQCci6$qkOXUk(LB~X@%!p%z|NzaTJB(|h%=mvz42zG<_@_W~`j0dlOx7PDQMYLL$h*qmn*P0i&fq8+DF^!Rw zARdVsNdglYWE<_K<`RC0%mMdU6RpdmpRql8C}i=?QW4)Fr=l7*7nv! z_#leQq9JaZcMfA5WSU1;{?JB4w3WrQ-2bt7= zDHF@VQ?mJ5$4o54r)0dfv)vXiA7jN`My`Vk?OV zb|>`CYiGNG-^C^B&xU>|aWkj-acqr#^IKdnaC!mJR1a&x^0Ae**N8KbYLI!;8 z>|2$y;2i9$aiCq@WrnJsy1d zr;vh^#ELKPWS(#BY}zE!;MtSxavqcaatMDqsrZsij=J2r?zZI1&2)i`Rb25M1C?EE z#TQHgkMmQ5itq6N8o&X)RK<6^j8=L#S6g1B*Lnd2s?6&3WxHsOYFP2rX1tngS89|l zt@s`fO_TA~*J|rA<6yaIGSiEgVzCT+LD$W4dw$itIGQ6o{Q2Rel3fz-5VfBD$cc7Ahvaib=N8G*s^kC2@uYOtemE zSio#a1lvk)yNaEBMB%u`e=# zvWjoS{3*t}R*waS&lHWP(50^ZAn~OLByi zR}SAy?e(84ifEzYTCB>OsI~_QXN{;;fTW1TNrEp_7#B;GFGDPw)|f zy{n2Zu)^>l3uH!;T$iL|-(}8qoA)mTUsk3opbDo8zRh`7yDj*}KVQ)^T`V(g;Dk1o z?P8hp#l;(dp50vZ7TXqlmvDd5ds6P*{+UVDHy>1@%8E9Y z@uu@>MYijR;|w~^@AFK$8~r}}0q2)z5T8!Ud4$Rmd;SHA|UK;c-o` zcE$_pjKekSfYq|uvhP7G`X0PwzLW7PQO`z8;XPPdhs*J32^_DU14lqte*1hSvdA1# zC)zHoNOMY6c@dsAugO!=*-C2+m370;s@6u6Ac{gHy-)@$D_aLaJzJ6qzadq9oB3=C zr^54-&2=7=;B^Rp^;GtKODg;3PIh1FzB8-BFI69kQ}~S(eH&J;%hz=V@>5yiH#)H9 zvJj8_K+2S4-}FO&tubINzHFcL5#p1X&e;kT zdjWZINSv+*#pyt$yEY|1*Q3gHZSrV(UG!M&HPLRS#O!6Lp@0>F?dEnOf)S?gy#$CZwSy%3QGc)xb2JU$WhC zv&0Iq1FQ9a-rZ`wIcvS7f~r==OPT}!IHJxVYY~@Dg`GK%5YTaH7Tc>Y~m9sqX-O{*=)E=E#PVWXm1M zS;=j85E#Hisd8RO6+0n)-Lj;wW%+L&RqR5wUK#R#5hNa0({j~o33We^CCDVX?k_Y^ z=e)UYbN54(dht{jNlkbmC8@vshbi^;n3)(}%*6}AXEo#>xYSGQ(`7i>cqsKsx>9Mqn|c9Y zT3=~>4{OsOmCU$05X+ShrOM|mruo)$ zwd9(VsvTRq4nKG}r^nK-OuJbm-(xCaRIc>P7X{-={kE3E$5gx9LdmbR=ULIeTr3Gj zv5%gq(2W2V>16nqi)5Ho`JK+@P$vBAtHt^sT_*hNx_|JSwdNcB&iP4CQ{CcX&B=2? z7r$gQN6%BHE}b|Rba7y;4J1L0l<0YmSCX+kM4 zSVaGogeUxoF4=wOy@-ySC4R{mX5dWyx?GXm4>6#e;x~+0L3{;S6nH>FY(Ee2 z%|DFepEl7`AiHJ2H{vkHk764!vYVkBA-0uan|~m(lc1Xrw!fWjK*$5~P4R%}@k$t3 zL~zQUk5l3UXd#b}o(Zv1y*yeJOZDjz@<)LOMM$$`k~tF|g~;_mW~j_SF-;D|trC5* zPJb`-ptP7YY4Zd(tFfBj@RPyPsPwM9q?#(Om)cX+RJlT|$uW_-{*VXdtGrmTSa67D zWPg7gB-((3388}y$<`+iXMl6}0BSW%tH#1DFld4!CNy3ONZ1o{u?6UNFG`<=NY!jE zIKuaCEV$sAcmnJ;LSWMc0-D{r1{?w+s6Kr{WEfywGhN}hcxcMlDAdkh$D zAHOw+@92O<{6Qf%)4cxq-FW-&`AF9v_nUt&U#`u!fI~fe!nd1$&kw!bo_`QH^cFs0 zyZJXZj5dD4etW_;eyuh>ueaNIMZVQ;2M-+T2Bh3-~sEX#dEGfnno9S4-m#YU7#Hx-kW^r3CeuqH*%LbUf!eK5dg#8BufAmy*U_v zgm;->{Mp$ZjShB~^M6IZ`T8)=Xav8TXB+|F%@w%n>)4%reVAt(zhWP~v;BUKJ`nJC zF9>)v(ck0G(S-hai2%Te&3g$zG@F;|HF(AKS6@R{+#-v>RX6!tG`C+tP{DSN05b7X z1Bf2k3>3QB79WMK*vIc`f0*N2=&J4R&N@KEqX^L+ss@fi0DsqV_s~QSLZH+3YT!`_ z{aWsRzWBlI&j(y_`}2Y0=^y}Vx%>IxtG)*B>h{F|?|BDdTg%;xk$iCO%iy1=BruB4 zbT~fKv8bS^-w;xG3vm{8z(Bdc@+^7{sR91mss62?x=$iNFPF0^J%emm9hH zVClWXvD%JJ@_(KP@Ix6-I@KCd2=NB-{I_^n=QUh-LZE)6$n_R45WI%VC4FLbotC_l zEqo{6vdcO>Tu*7?dxBJ9Uc>hv*{9M-cxx^Y_t8Qi?xTN!xN}EbhV}KCFfvyYJ$+tm zKnUoYM1^5wDuFDVO>T+g2Z!}Mwz+{DAU@I&jMwdGhwDnUSYg5p8_{E z5evKX&*ZQZ0y<@YoVfbQp;X%B83i7Ve891Ul##;{Bp$iak74{& z+Y%DVg<)rN#MNAsKJ(nwX`<)#tRMr@(;eqlBy1jgiQQy_T13)Rd_R@2F${o?H~e{w zTYn3DyiXu8FT~W*7CUj9Pu$TG@{~3I$so_3g}hQB&wGV;-Os!f-^9aF3kgiDG#3K` z2aGv1J1y~NVntW!AW~NsUk1V$l%Sxz!i8KD1Jo^w=%pZsq>hOZsDjiQI-kxkLd^Wt zObt~`Rg9Q}JA6!RkHw5>F2A+;%#I<0@PA|M*)cR*#iFd39XxS@ADFUZ4$s5&!V`#w z8ls=3WXgc&fiG^um{Duw>6tPt_rJRE1c#9T{cg}Ntz{+ftext@6HC56f3w7N`}vao z6siAlyf#@Eo>+p)Rvov<$ge(9>yn_0LtS`c)BNxh?}>adGNURlW{0YU+jnPo!`G7O25zWc6v3#05NoNGu`R zO8}0m>*vY|Pf#SIKpdt%JORiN_Sk^{y~ZJ@O#^RMU=w42d(}F)htiBm(NW-u*?bDa z6Z2P#S5q}SF?;?$rfgM5=-Rmy&3^}Es=7q?PIG=e5HspXfy$W4#}Ig2$3b02hAg6DSOW%dphOCjzvv1xyhMG~C#()%EEU zh@4M=6>bpuCm}x3XX*5q+kfB_+E$M!hk}ezT(ow?cob5cBHGuH3#AxE(3YAA1=#G7 ziAizU?BRh;@yTVg2g0ZjAr=XMAqQ%KATebS3mBy_K`S0AM(UIpnjXiusc@Yikar?s=&;lMB0+a6ba+vwJ24osK^ONxDmjw|~lFZ+s%zya2F3X+mb<=CHE)M6zzErG?$5^7B3 zSG}~$a*|1Q+@wsP(jAYb2=ofU>B%p%16_3>o(trv55xykxPL|fcUkVnleNq8@I2JZ z0e*L=L!OS0^?Kx~7&xacd8wKJUJFsy1-_PGF-GeGO90_-kOT1(jZlF95M3p>jEO(! zNo<}k?sT+AkPZ~|_L%5tqgvX8`}nz3tpx~*;u+GR3IZI2HHmusr0_UKh#%L{Sn7)$ zF}iZ2t*V$NFn`{s8U#J8p5%lRmD4))Of$V9O_QUdU2$G=cCrjFm8)eU()lDiPJa}# zqa-snsFtl$W0Pvxl$;wMaB{T6k60<&mcXzMKX#Q&?9e5wlL@@J3MdfmxpP@N{31_- ziw?hfx$)tSkURVYwXt2Th}hw$sEZl+bs5ygls;Yb`hVEhS{;5PNNyMG@f!h7HmNQ@ zoxc=O#iT$^yvgOBfTSy4GK{M^O`>njA@KtXhKZbbg1#6>xucWZxZ_*xKs}Do#}sEC zYTw~WUuNxG7|2`>KvSH8*h5D~h-3E_6@$MA4F)>l;aj~*_}9#$3lwKUxd2(~`gyf@ zW#vv6Vt+SjIi4|7?x%JR+*->mQPrTQC1=-)sJDHhLT!^9cWQ0knTc8T+{j1h!b2!# zEJX7WI`?CN_gMuG!Tsn_bgQ>m^O*cYP|Q zbLI}X<4S8^$7wQ8yC-C}P#HYCdZM;ZS-ZPhWXW1?HKa}|;8w?#NsYMG)bgO6$jI`#~bl<%DM);(se%NXlsU zsAjhtp0zwRR#7EywWy5Pe?(zSA4lb7CNU2zn;cc$KO4Lrjf+b&*cT6Mh{MPio2uY_VflIQuY{qx-Ep65CH zdG0Qr=NNWu@ho?;XSuu5y)%|pAAX@h?x9I6V3vP`NBbyRX zSo_AT#;ayqd|r<4Rqk6+@*9{Vkt#dOrUm`paE~EE?KNv z^X#P2l^q&#IQOz~H|OzPV_JFGU4yekoPP&f8=4&1$d^jl$bWX?(Xgu#l^;G=hE5VZ zsZS1Ylf)1oDuO17q20@iCJncIqmwDO9ZSen(`qJMhpjv%E!H?>)$(_pd63=cP>3A+ z7A2}aNhcc&rD(E35o@O5y7!pNsLr@s(n9Ial|pIZZpjRuBaa6PSu`ey#HY%LM8k(9 znyejhCVMGUM1RM`bEGp`=n-OKP&Tw#4zY!XsH5I>w%fMEwq#mpfPv{0liuok`vTg} z3&mdF{FbrgaM>T04i}CM9qc-&1f09{X;7T(=S7BO_}g%c4N&J3FLKz)fgo$c9|}G@ ztKTPz?TsPE1<1EZZE3%knNNt$CPe1~OHhdWuqqUGAb<5g5?b;le@RD+mVB;Mbqaa0 zCMLI^D~@IYHd(ahr@9Fgh|`26u$LKf;twVp<}~5kx@Y21bK5;6q39$7^Hd#+W+R`b zgVN>yl_wB>`{&y~50ae1WE=Z75h;tsq_q<@yK(DbPyPcaFwv9T>hVYO-5q8uHp zywSm;lpU-_l1*!rJ-Gl)EkF|s(6q9J5t9PMJO|a-i~)pAJvLZH5G^Hw=yF96YXoe{ zZ*Mr=^)G`4K(sP~gf&~v`+4BdoQD@%JB%ie;N@XvdPMBvRM!_I#7nmn^+q}}HrhWq zV1LVl_aFgu{6=;3T2S(CCx%x!SO}P66~{}Mx*OaVniN$5I<0?QH#lf1GL4l3gcgn+ zPlD0PK|%{ff=5B6Bv5#hqgJNG8XjLY?V}87;Iw!r5Qj(wj)6L;O{I{*#AmHRAQ-2= zRwP2&Bu+vkz}lV=mGD6Q20@5KX(Uw&s((%W2IeGoN>z4X*+dFk^P##S7kH}(KWr$#ttERqivu_Od(Qly_PdT~ztX*Nmo1^Fj0 zI{;(r>C zGcbQMf;}>gb}fI{4yI5BP1AzITEIlEY8hCIx&~Rs)hG8Vb-RaPU~S#CD3h1Ax)iEg{gb`)Nqv{?mJh}LpTwvQDTjWE6 ze#*jXAQQdiv`P--T?TpGpQl=Z-B(RpP-S<&A+HyWOLcxqHTmM31(bVF4jx5-uVXhs zlqQW&&Z(-Bzo1jA(E)-xNtg_jgrVP1s4|=-nfg>OnKBm|KEL56WpUtK<$pzuBe&r2 zl1wrsSmV=ch~u$$Sqtb1VdVxq%1jQ9qr3@7L70e>gZl!>!ApQQe*N>iSm3>m<=(}D z_2_|@$S;1XZksQ>i{HZNp)Hke{5C$e{ULFrX?X)%=mx%pZs1$!2Db1Gd=1~g*YFMO z;TzZ|Zs4p~{?W(ZAUxoeXnzY%Yo=7`2$_*kNkV5GwLu*^uPX@Cdsf&y8E-yV||KdQqhJG>6Ga-Qlv^0}ZG9mwCK?8S7c}+i&I(nd8yE z&#yd8Hsp=V!SC*fNjzOc7A@qAEN#U4K&yKxD8Fp;yMfn(Q!f#X8K|NV0Siq$|A*u<$P z03ZuEUF>mKWLf#1pYghcx58!oV}P)cuEsr?RT*f~JwSNrLd)cDYZv*VMOXvVN zCrVBCTbbV|#D1X={e438*9p0l-WlC_|LrAz`EM^m?Z^N9V!$uQoBzw+yDhnnBUz%q z5_X=ZOM)dxP!W+oP-gj7R?qXYL;IeoUEMvWrzCy-MCeL_zT6#GQW1KnvfM!t1RWp% zcVE7i>bL#N{(pUJyW=01Ar(!vk5ya8s%>NSmbo+0-4Np&+ny*#w#s5~KYr}20>E4!lPTM+$? z(HYmbI)$rxi&UNhWvB70N=v)4UuSzHKcoH6zh7XdYk%+)s*QX{^e;0#qLbFL!M4hF zWdHoGC}Q6);bmIg}T zcab-sw#WvQ!P|b_gMR%xn$NO>lm|p%&=vJWVG{dw0IH3O+@#uUZun?>A>JY09S>=J zicRcgF1(hVz#srCX9l(uTy%(Fa5F4Y5`IAHvLS$ z4#mmHVw%x-86PvAX_XxHIte=?we<1uGht$y8F4hA%22ne{cFmjdl!HU=mL)J0qdB= zQSfWZAg$g$F2Fv4>wGs4%#Y27__1@q`v6Q<2;xWjw*Q}Z3uSN2?0bSkDBOvQsh$I`o{$n#c`i1s)G$hNqPqu)q~8uD{xV9DAo)BM%6Wu z)>Ff#G!&u1@TocAlkCjVNzo^(RsNHe%YTr|hyW-#0-)671t>KnK*{9*D7mZvC3goX z5!np~0ZL6BfKtN%l-zs)N~-{Xk_!MRJ%s?2nz;j%90Djcw+T>kG(f4z4N!V4vIC|2 z_Wyn>2Z{wKIR>EAZ~&!-11Pzf0F)dLA!^b9N&&5ZV1SYn0ZNSwP-x2lj9O(43JEAT9`%FB%Kh9s7chH^Y--mMlV4(#3r(@Fcu=pIa-IB3?}x5Rs# ze-GdqB-$N5SaT2Yg5nGLoplPkjej02x#&O2&xhPpSk%Xep=vP4asFs5_%P)V>i0Uf z%Ry0vY=$6YOuj?bt(%Ex-F%6k_yf2i?KDNIin*zbz}|-!C%kLwB?Gr^LR2pg*lSLr zdb3Yqb`z&CONi>FhdbWtTg$2HK@B~FB=YiyL_$^d7~MQGlE0f@D1=tcTYrvq_qK}^ zWj%T#5JNs?$mH8CO6v&{dWKiUs})j-m)6@#f&HZ-?q4~C^0kKB$B3G3k*Jv=uWT2L zeA&fg16PqE+FAMqs+*bK)oImK#*P^4-T6+HRJ}Lys1~Xlma#p9w;3&V;aVagQqNA5AvZ|`GPqp63GH$$DOQ;t`h;i~tDAQ~nHi^nKCRV8uVgWu9EpE^;d$bV)NRAj%WOYHZc z#C{J-?DwT2y_iq~1fQs!=5OYsQK6CH-gD$4- z{$yiQoB}@I@+qKiMSm7TwdX@}doHF|thjKK3GK8G#yahTkxu(?{86#;#VR}2r-{^> z*iB5ado(3DA=ate$NF{uSm)+p1kNEr)<`lhW_J?DnXgNC(rFlp#-`XVzT6NKBkMdu z>PCijF~W(w4TohBqRvPgsEFe-DO(zYx|+$>reRAfv&HHADSz>vMQw6ziB0ZT>L&N= zb(1Sjd?ffzOsP%zO^~A{BD$H6^N4BuWBF9+l_x4+ODtK*yNRx6x`~p>RB1D%vz=Kt z)tC9w_(X52(`{DbhN{@G3UK4UI%VtRnBKZ@qE79LjqCOE@Zx9l$7bk@eZKVsUX%}p z>g(n6?s}ez=YR5QSqMUZ6P`vlrJqrNVD;6_NME*pRm#MKcoU4zGCsSP%gkh1-e=`a ztlvw9Ce~{ZeHOy)ndRviYy;Xz)SR;w35)EiL@;NQy2`GDQEbr(3lYs$>Tpcw7_ucR zCS5hZzL!Kr5Xvq7aQtOzwA%s;{ve~tS=`T4qTUvWdw&AGkDUX`OOr0SCqE{1N*+t> zmoGW?%TR<^)Q*{i6f5nQ@QH~mJL_zaW9#>@!)8N}y#v354b7MEOC}K3ir+aDD{~2g z$+T*mKW15tQ<7bhB}SNibc<$|+%C7Tf8CnhoQsbB$bcW439DrBs-p=QB^5*lvy9dy zB)MwWEq@^fJh%~US-^m1ICVB$m$%`3tER$A;YA!qP0R;-TbYw&t~kNWV%(vHTlvq1 z1?adEVflaD1_ER@hedo;WDbh(n3(^Ncx?a3`jG9gSTOtfM+T6~EgTq2pj>X@06EKo zyyuTykQc~1E}m;hhI9E`L-L3#4pD^R=5ZL33V#hNABMUbh^h$g-q@nX-%D_rZXP}- zbD4(3*21VvBVui!CGgU?eXXy;$aO+#7D$Np?W1qlwt|(1u zwfG#{3-1M@x794iEX&Lv%Z(qnXvrBNk11mt zo4N1xp`nz)w}tokG%D3eD0Zy?RN)A5LrHcaR^)t-i{Ab6v-FrZEz&ds0}~)dI#R_j zzHml_9BFn+0>77pw5DdIS2V+|@21<7(SKb2*F*?BAmd&=c@PxN{1P2n8_P*5XBY%)> zun8zXmXz6aZ74ysO-LZ;>WfvZn=e(Zk`|@RX%`#mRkdZjs&%qgwa)HVjmo6RKUS(H z#{N+-8-uc`N!+bpx8!btDk$bsk+S0XIvFXfI~AM_)scx8eu^!P{d2;iEd%~ahwe0E z^|Qv??3QE@m{fx@;O~d@q?C63QGd^s>VPW+a)smtv*q-yluk&t?+J_&P~|o|POn=r zR^Mtl@g3=3!ObyU_yK(xbw>SCp_7(&a%`eHWm`p-K22mkqoc_5Qls+s zQq$?~N+=PwIn@=(JXw5t$`x^>nhg0VJ;j8=mFj0v4I-HkGFF9!h$w%^>wk8tdj*=C z#Kro08!pz+WNhv}Aj6fZ0OHFG6^X=h1`}R0VX|uyeV&!$BRhI?XNxk$bTWbtYm75o zu;w)FJjsTt6+3uWXa`S^h5jnGfit*7MQpSh!-up3XNj3IO-{10f4RktT?FO@#!{6j+Rweej%@qq7yVaSDXv)!sHTuE04Z2`3 z>(+JZMdEJdwZd;uZ5cMhziwiK0$U6CF>0Hwmn)MP%D@p!zRNN5K)$2wA^n_;9hiI< zi{3=SKK`hZJ|Qu#A;OW8!j;f2#<@zH@Av8KW7XENYTH=7W$YYab${^@g0Hs#PW&=w zaN_6LfZ5iw0<*o$2yi%mVBKSh6ZSgC342}qh)t1|k@!1Y$6MgYog+v@v^2@l`{(T+ zy)ly)gTxcdS*JbnO%VI}{;qL#-`*9j?(1}iY8sM-oQ^KQ>FD%FYYF}FWlE<^Zyy(6 z9~WXD2~YO<7Vu>2Ie+8HUTy@db-OsIrxNGhmmKHboH(fQ!MB%&GB8PM!ex2#9)yRz zOg#+Ktxa+u{&CX>;(;OgB&bPLN!=u9aF)?gr%%py-9p=NMcmF#DOt!IO6 zXTvRLr#==}WL5a`t`Kom0r;S}tl)_=w2zkVp}m9lCIam}mwz~J{>pLO{8jC=$q8?n zG+N<;r_U_mSlr_M6q%&vEP?>2^p<;T6L&gw3$P>Xktn09eP_1dYfOEBN=-8$9$o; z8h&m7TR^10{=mzgl{tTw?i)B{znO8jIO=Q+70>s_3p2+V|B(UgP>_^+c0l7m!fnlQ zU2*D{skdk9I{cVS2sZ+(94v*)+EO{FIb5*kyR#y9F7!Dr=7pMY!?_se>s!_NFf(v? zm^gBb$Z?FEa+EA`LA+8%uzR2oppnZXKWohFj`$81G1%X-l%OpBa$bS>6J=uIz=>E-QWH*-bM zca>ePAPG8&&zy@tQkI9#%_UhOyF6!^)UeYl@*?>2D;R%l=?JU5ZS;p(WWsZoDrNDL zb1+p@h&ALEXhoO|%iu9AhGJL-g<+?THRKXbMKs$Fi)M#!F;H+% zC6`Slw`E-3&&e+D%fIu}_P?zQuiw_#kt8l4y8fm2%hRdX|8ps)xOdiH`ERnsmsp4R zda7S}Z?=EzUnr4UKBQfa8Ro-(>~d-moJVz12l>m5ILJerAtw@E<^>3r~S*j?udT`WY1acutPeJYzISZ(J=?M{Q8Mq z*}6E(=QQB?IX2mmUvlwvr+nBXWjoAkBl>KI@e&b!o+2XGp{lg=T2(n~)__5nMG#J~J4msIR@FUxv-E zFzbKz|6ik04aSwXP(6frZ>Z48UQnx$^khpId;OouD*Lve+>fx7Mb&=VB0#!e&?@Zn z-!sZh!Gx1Oi#6+fS9GJo82(p{IZn&Q?C8YE4&+e|OC*A7L$yEh0ox^ngG+(d|Eyy# zOIpj4wz8z9ENLf8Tgg&3vb2TxDDS^&b)18+e7Ijz@A^RR!Q zTw6?cY$>(2kj`jCH$K(tN+gYN+Z72VuOHUQq2zVuSTa#(PW&;UZap%#Y=~NS|8HcB zdHAD)5lkZTJtQI=obHcreVUl_uN#Uv!+LReDQjJNwit9^bvqY}UVt!*j4#~WN<1Yv z@?^8do&Kr=Yk)A!ExKUHD+V@py5fIeYYugQLcgf;)qmE)gm-kLqXUtS4n#WWzdO*5 zqaRCJOXei{IjfrK5JkXfrNQ_gyLwgLtmI;u`k}HBfw%$?6Iim6M4z7^3ee??K14Dj zloSW((!&;rLxSdtL~Rr=eeNTEW}Xi6)%={F!R+SOF>e5rq7&1fCe zPc^JqLSM996^PXQdU#0!{T%`|52n#8j7|jn8`fIGf(}^B5v*QHR+C_TR3LGc1{?%0 zoe@Bo@`QU(XI?x90?s8ba)7FUlj0 zbeZ;{B1~oevFlm5mF7TQt4*zKc22|Py(+0ri3l<6;XB#}qHh7GK)h%qPAYBj~497AfSafC^ zmdj1S`n5&?>m+qUuO9HyNrML6)hLLKztns?i{8zmH?!!y>LDp+?sCY@Lp`(mvtCh36Ka zAanwO9W@rZvmY$xes01VcmS5`U>NaEInP+fSKj;;@$X?SiBIg-M1?UF5+ zG-JQ1PY=qpy&SWG3SqT&*m1@0M@*n>_xXZT+Lc=j`#(LN3?8qy=z@{i7&{QAV-EUs zXwO}ka9|itqi^zOEn8aJl9sWfWvpm{4K1*s#rCt*Zk=$P5DnEFgVt3$%7sIx4$C#zm`-WTPc{2#Q7>b| zbB{&vbH9=*9nXLGxnDO0pUW(_OOhLN#K)|JP&Jhx&M3tocHvK%CXV;#9cHJ3*%g=M zW$2A%0%V2!P;PCZpS5ZV^D!Ecc878-3&;s8M1AZW{Mh2qtEpgX-|vu|4*|XUHrLw2 zd@|7{PA59Go{hDgX#$ZYWO*9Abaud)OAT%iTw)p&?rML^mCzN%Qz^8vXCcevf@PVS z8<1rRiAUWy2*}PH0u(RG^jHK0^edT9=?w(5E{Fo6J$p&vS7KUXLkn3@K_1i61sO#B z9ioFgCZ;|@s0bsIfZ@r+%oOsJ4;N)fLJM*Jx=!aegtBL<_Z=2C||=WY&cE?e#dzeN{BTQ<;^jGIB*(#f`T z3R`}kEz=Q!Jm~_FCtXaSAsh427?Tjdezd?|2OEu$#`{~1@fP3*4EkWfl&DBoIZOlS z$bw1q&LcXJA;BK!>w%{1M397x@R0?RkY*qxvxt92#8o?KYQLtANR*AW%>@e~Mfc`{ zg=la;n%e+;DM8NnkE4^T+Cl7b4l7hxbul}3LbQ9_?$AN~)^ zmMe|I1D?GL&_jbKOcQ|k>n30hJhP7xd~N`H;Q4-H%asBtqZ3$#0CjXGHlaVO%Ftcu zaTb3fp^l;N!1O~So*yHT32}Fr@NNmWj2G6SV`FLOoD+>3xEg%A=VigZbE#}1?h?NSyGZlgZbKmgTj(vZra*isJT1Xui;;^_kP!DWW+_Wn*8}c=n<=7|Cm(n#+* zgRl-Y-Zpf!ZTMJQ^+?;`aklzVw%Rea!6Q6_6gOPfBK1?|AS9v@JXuDGs}z3R(i(wK>(FhJqlrvGhG7*tLi4;h)R8%84qLXAG5``5qX>(CL`5_h>5Q32HNv$sWHyO8L^rCdOniTSS1J=#q8X5) z!s$ds0xMH?+sEnqZHlx&V@7@#vayJdH8n4|-B#hUOtOiJgt^$Z9UCdl3@O4rbu?tUp zcQJ|YF2L>Uf}IT~v_F6NhhtxQso~J$90-@hcAjgzgVUlM*?A0XTLg3yJxfG-=cC-u zn5B%*+0KkDt;>@S#bOMo_FSG8FIC9x`26IuUS)DNQo9z_G+0#8U{O5-v+_lGGdc3K z>l`%kpVkEvwIc_Gb!()AtauGq~DWOyGJR&2n3lCfs$?Js};p6X%$Nr0$JsI@Iu3G`WZD4rUe4C0@i%L*gCf z!8G~vG3{@ghfRO73$uP`!zh_Njp&QSOa@i$NC)NkGvnV({86g29Z@V%dR4_7kdbwx zRj^}QpT^<%?`bKN7Kr*4=LPbpU}tkEm|K`9h1ACaN+hf=rE(blYf1{F5W-)Q#mLZY z1Ii=%iRSu9#!mlP1xX9O5XLElmmtXctl0)`*wfE|bGv_kk+I*uCabwcggiUU)I-|M zE_Z=7w!mUmW3OMc)zw;RT;PRH?FxgQGwNuCH5{NAHZlY=o|_t|#4UF7wpgbgu*S(~ zx4-JV6L5C7l*ddl=$Wm;m;JNe_<(`f(gO$HNRy)aS!;YOH9GXlie{TcsQhDkI_?;P zxI_xd(lvkBY`$NH2`Jw0Tb*$On27*Tgw>7L*UIINu^A?w(H2z(n5*$>!V?d?b9q?p|;M&-D8T^Y`YOhOEycswcg z^koKu87^2bqbV2js~bIC-$Y>TW$rQFTaEm=5|O`O4=<_Q4Zj}R^@C}C#W6Q52wnn1 zUP}oELW0Vn03Bv-AYzpQdqZDv4$x3QDGz@N@Hpzi`A_+n8~lJCGg3(b#oSN*&~hG_ zNr0=|$s2SOh%Sd8nZ(=xqobe?S z*X8(2OoL(NWe#^^P4nf zY0>H+UVSGdoT2JG4?pX3(3B#ap+X;V+Nr5e`LGHiC>Fs7RwSyHa3v9@E_D16MG(#) zq!-_+5MT*c7Qum)AZpB?v#nDbX^aF^iSdk;oDxXJy0x*ANkHw|0w=&&VG@7o7*Z*a z0$CTgG$qE_od+K~ibHKVE)A;k>h15~QI{aZGOgw(qYW)>L15a8ty;#47TC}N3j*81 zM_sLGrw*jZ#78{6Q5Y?fhWLo(HxgKprX~$zY{_yrH7t!!DE*I)HVED}k3fqReB)&) z-#a=eF>yde5%*!DMNNFPXkC9N6j3tnHg%vyq{s@PXPy;BRwJ=kGb@y3$*Q9QE?pVS z%dezrwh?KfA^shB}^?GCsKk7^ay1TR2P3o362QAW$W@p z5@I8Dn|@gZ8^vF9fH!r)@FpSdMFO0h;yDDas0)WUiBU7+9;bMa|B6n2&e-^q*NaK> z!9~o6CSpD`G4sL2%m)`SADW2y(1gzi$^ZOW3$kS~=&fZyZw&7kgln~0mMlQ3HE3xL zkU^T=Yyr%QBi&0rdTW0nzZfJWg9jL!R6jQ{R4b;QH3F&?Tetdc(%M4>NU#WVR;C&~ zA=202NS{lK^nI!McG)6*jWW{rxgPik79EC^DeAzu6`^l7usAfbYV@UQeWi^Njrdfp zW-+v^GAc%z1(fzHnGx&&N?TVzrO{%}0di;~t}t8{m=l#o$)0}^9AZ2=4ItB|K&54t zLeoC!gT_P?(P^A$bEGU9;2x98|H`7%xR_|1EE*jP9ja-+gP5os4^4}Oa-+veoGI@a zg?H7_RI`tWi_Rz}8p8TBLL}NCJfe+3BesE*K>=t)8-zu)p-99u#`dD)Y=cHQfWt^L zX!JveO>wv;&Aq;HlHayM4@LhcT10LGL#ojTrW zg-Dnh&srFWI#0~s9fg`PpqK#+{HaoKR#*NW75`k7QAB@u$swWo=_R`)!Vp0Q;FrZ9 zqA8LIy*MUOkURSONIKyZX{BPP<%@F3fTTATg=7`LQ>F=f{fUw6rBp% zYzP~o1fo98mQy0i!^%R~u*sq#^r;wM!@1_#_g@6Pth zhk7oPrCMhY2Kn?dL7FP64wMvSrQLt19BC>-+ayQ|WMB=Ds+vohYPuBF#O~J5ca)yW zR4>Yiw{q@Qne-G?NQssP8CW%*7Vk$@pe$Kxw+|Kx5TUQ6On?F~OVk_k09CTw(CouY zA@`wt{|X?lqu-}w&2X~I=z?5EjYo$-jCtKawPNrh*iT*xy1&UT2M}&-R+Z1T7OE?rza+!@T}v|5s5O@J z#>-kj-fS#oEmoO`DXh*JR>_i@UT4t~__VH&n;zwlcM7pon4^xXHjc?nn^ABDS00Nw z>N?fNY}UB6mH>Ft2MwAe=BR&J2L3oLfx%~#tGYNfN6ktqL}>}|!4t8%_{WV@7mFc} z3{}ON(%|rFVo>rNN++JNx_HWtG!QwZI#q0l`8Ef*#!>haaut|%Fyf*UChLMZ=#Nr+n zV+7bxfJXx38|n4TI(9S?P(tHLs@8{($fxpd1#ip-7OU;L3uzvA>idt=A^*_bqw4+(E$#2mv>Ayx)^&{c{ZVV>l zEzo^A;vDM#AX=3+&jg7IB`;!gDG#kX7&{g?f5Q#g`_JrizHB)~Ljg zqAD4bWh890Q`jw!>`e)M9T)LNjc)dcqZ+G}6Kd>;L6u^pDU{!v5J_VYq~6khrWRTQ znmMjaAl?txHOqgjxn|I0AxJ}j^~kvYmii;Gn?+V*(ji+WRV>?1vsrwvVZA6Ky8pIGmmgMCaVL`hyQ9|(0 zKIW$c?`)9-_&P%Ze4RN1K1Tm*S-vCL=#&|8Lr^EEbi)waT-6LYlOK=Fj&r&3+L>{b z6`u{Qg+3F0?#llB9|bewMawR9S@F#cWyMj6MGQrHHtN6f{1aTZT%h)wK5HJ#9}<`? zmonvZ$&!Bu>e1KZ9Qmtm(ldf|gbKm#g9E+$;P9&|;Wd64}SH)Qi*}a zk%JpojkSd!f%rjrAVc#z2G*d$2Dt(a1S!)jeWfNAsLKH#HHxYa*=Bgd1D> z;A&?i$)k%6K8o4k^PY{sIJ6O1iip#z4Tm_iF;I$|%~;0f8N&piWu50T8Ots`%cdtv zvz&iwwIVSWM!eRecPm3hKVi1l$(LNN9C1@a%d1TKP$M#2CL!9Tl~fBE#pqs=#7DagzSIpC0nub+PY>bR$gN*M>=IupRJV+J zGfCAldVOq9e+ZJq2i#eOsUsu+jz{j@;X-%F%#K6?PAwd>x4qv)DG~TH7k|XW=Q?dx z_s4Wu-M)b!2OW9UGa;m)Lzi<3VuObi7sEy`$6Jmyx+3e_%lLLI0b&Q4{|tF~NPvIX zO;{Vv_j5*d(Y(R<(eMT`IwF-V>nm!Z!^sRUTl{re`2Czd>OG|*2*@?*3s(4$EE*Va z!N($WLWMMG4(aQ${FGKvl7FjQfk%Z|Z4j?zK-@k)D6KEeh>oz^;0U%=<`pz%5*QI# z!qdQreP|9Rte_+?o74tmsX|Jr6ZL-}a%F9gv94z-1h!p1jV~OJ)zp8q#M9=Z>-+Q7~@)PDnC8TNtg`r$#RK7#(CtB$?e3>z*J&<#o|@$wfA#klZBjCNKl zB?metJ<)M7`{-b9wy-#yNT;L`nke9sMU&RzFB^VNhwK>j(KOc!(ZS=)7Hog81)s15 zpO6Jt$bu_u!3J5-Aqz@ha-Co`D;fgg*bEJ7!BBPJOIZ|wC(lBbtFv+$Bh_iDAz32T zZv{dxpF=n@7svhQj6n3lk*AHT8--{o?o3*Il`8Q#Khb!PG=pc-;cj0$>eZ@6O@PlxH#z$`lTD>Y(Gu*LmvuMA6+Yq%5ibR z4|+DdoYTgt2gaeoC&M)_fUlEN$i+n)!?h4}d9Wht1z?-mdkdH{c8+Mtq!6Mk`m zb^G0+!_SI}CU^ai@iUD05ET!oM9qn!bgsAJF#}`xk$c1SBfJKjnV1W7fm~>QWf2D*5BY`liJzeC6CC%l*ZYMqcmmNVOES8g zUkv=!ZS}&n&tzB(pGJAl$k;A$#wqV1`!;zj-O?f*+XX{7bUO9W z$2U+nMm@-}Z8@$REbSc#6kX3L;AsXv&MMqrKO+HJTlX$gP#zSM%{cT^;u-SwE` z0W8X8_F>Cx;^#XVlzBIsm^wrbv#4-@K)2k4ZzAyLtpMMiI-YLPsufO>29WBpWNd93 zl)WDCyTgeYkUbDmGg6}EQ>9mPd^a7+nLOdQ1{<{^uHKeGfUk2Pz(3VPfHw#M-Y5{@ zI$o|+B=~~h=YoHcScnL3Xhe8Z4H51zBHTqI!u4ET<qzj1M}jwjNbqppul$JcK>jZ^GQ8oC;mw4|aK|IVT?S-$I1gBTM0g+v zm>LQ0I3&260p2i?tRj$rJQMl23qYfau-jVn6wz4V(7u0!y=@4EeG`bo4w(%Z30eqX zZ%T|QC$RSk0ee>v*t_C@y^aO;Itc82Vu8I+5ZK!wU~dC~y$%6;9S7`v;()yk3+!!J zVDE|p_DWIf&vjPhcwBG8;d)s~f*?dNOO|nE6X~)di5)@`$6}9{GQG&TUunGc{KPQL zv{>aBr0styFW9$Y|^E=r1Tx&@<3t`1THv&;mL( z9u4gjXy|4dG&GNZjYmW`5+d5o0};)$ek7uy8wDEL%?AyQk@4wBXeS||o0-LWDFAB| z0bq3z0M@4n0IQ3@M?S@XUYl^x>naBH`V;|rT}6Oin+VYBDgqo1ct&TyrHhR3Q1`*3)Y2gbXQJ*C|-JMNY5Sv>ihMW~$%0#%V zI_ww&3XVgtrXhxT!sD!@&sAc_FjQ_)c8-6^%`tfai&Ba)bRL7u=EQO2tVm-q53g*v!XXj=Yrb$Xh2`u_K?#cjSMR z6pvacy8H`8ld@7Yp-aWDI2e5zUIe-mvqHeic95z31 zPVWxaUfUqA9;-;)Tfm06eEj3_7yf^`er#`R=&Nt-w#Rm_m*c;v{qof;GdV5(Q}r3~ z$DQA&JS~nxgoC+5CT+YKM72JeN8=}Xvx#`pXikw&@@H1UdCz4$?=9)mC}oSwnK|o< zrh+N`cZn74b@Tn3c1b&a%)N5V*FRKU8-{mO-d|rP+FuVZJ+7*G{qxk`((8X0aL6IK zLk`LPZNF6fCGwkj%6?)s@gI%(N9J9zU7FUc>%@f1ar>b+dzo8O~`k)nZslBVZ@|_65C=2 zsdKqXnc$*vq5M2$cFUlC#3Fy&pR+r`Dcj#aC*A&vd(S5i<#ey{u3qk>FQ1*DEqyhw zW8S~q9=m*84~ADSchZ;7R@(Nj<~?LY!~yD_Fm%BI0k-)3E{+&eg&C;NjzXD^K0e}A zMxS8Kn8xTwqfc}ETEquRy_Wx(CLF1LpY}*Kys-N1@o>Z;Myfd+avp!FOK*39OU>cZ z;dn%ynvQppcU>+;ANqcu!%>^I@9PU9`x2MV_9gE++IKa+bhht`zUy)+`o#KJ{KOg% z5IkQrns6aHzKfr7Lhvd4oJ_x9Lyz=j5|oMLhMunJzB3~eAI5#o9>z@!Nu>pHnbWx0 zAGoD>}gnv}8;tu60ufH(IdsKg#JaD89UCupt^m~4^&qMQ&1o(Mq^9GQ{)Uao6!`wtexcAd~~w5S!p^TC{a-;`gqv?GOLUKG=s#Eq|7h zzp+fuxaMj%#**X}*tsC|u z$LdM9Sl=JHx#e?UIP9B{I5F8jd8v7zC)UfpfAW~_pM0w8ZaMrq2(c*pD2hd~0Lx69 zdai8G=gNQfKXR7mQ}U1Gxw0*Fu57_Eut}-mN{3p=oFZ1^`u+;fal84<#S*-=3Lp% z|6JK{=gQU1ohw`JT-lU7SFZAt=Oi2||2cVLuRMSA&biKqt`0ocIHBsnDQXALFA9nW z&-F{FdN6D4;1HRf6I~aYIt|)U-dpuxLh5ufk|OHELnVfM{otIc2g#2Ul^F8%gY~Kh zCrflMspc0<1=Dfv9e)OXVy-d??(#VF<6&E{Jg(m}<~Vbm9fKd2DNLLoozD*!D7NdH zn2CSMjfaa|G={?kiuC$DV~%U%;gY_wd!1uGFC!d>FsC}lQ1n8*I!D5XNT#bx3rzn_ zUp*pJI*!Xm_J?ziR}K00$H@=1aw{1RwC|?W5!H#D#+f?b?hhr9(_-dMWoL&{xb|-+ z16BpRV2wA?ci<$hc)wV!`WF9$v3EU+Bh#=Q4(c-Ot;a!QO~g_6rE zIKjsPkorv`q~7EXy`6G$qY}$eVP^J{%kzViOD_F}*e!HZBF*sSc1SCawxW}-ph9zB zYV+8nrYrYSvT`q{D$6OjY-S_jvYCyB%ci`D%j&5}sr)8!-OjpdzXfW?`4b|2l6!yE zK0Y{g$sV@CqiQw<*6Yu=vR)qy1p@mpuAj1^Wm&JU(ZxNEF7D;%V#fzjD+&URFOxTK z*_I0k4Q7uv1O|u;fCkq$BsmZlAf8ZfU?)9nt9aJ50*c^AOha46vjU|W6GVSA=Mu<= zZ5&6p_v;0Nks98moTI^QxBgvlApN_PGYqs{2>P^{vsx!{0!oKJZPMURCKLY2O9kv- z@7?xKLHp%y+AUdVBI9N}Z=XvKcp{_zhk+e$%lqZ76c{sTRlg#*@q)Zxf}fHr-no_G z4<`<{SCmVAYznw->twfW7Quh{`aay#Z;#lA8(j3ylHQ$_e7M#4sn?cQUVjJezFo|@ zVH_Zchwpc~^hW`o?&aoux|Ha zGNM$494l3&3baVn)|>7xtW*7kUzr?+(Sm%v@$kpq4~ARb>G#JU%x8a(2xzXlv+**2 zXTvwvs40I^Q0wntYD}F($pXJ0{oqteq5f;+(vzQB(WJo_&D2sqsS?w5z>=KQaWWS| zY~#FcUXheGFF(|@??Qj5kWO;}xaXho8_)!BPyg=gkMRALH1IifaJ@ZRPwDY?G^2=8 zx9e_PX?L>boIX&KjPLe-H(uGk1l_1%cc4hIuWb zz{UQIE$gC{144hF)D!7sPo&d$BAxo&?1bMQS|@(wpPdgsi9TmOPM{9oJEsL4N^QB* zYAqLH!JN5F0y_Uo`L|SjWHiE$~U zZs~{PHX~nNM|FP>*iLb1?n)e*Qy6m0KjuSD9eGl&!^fjMyhe)NMz_wNi;EO7|Gb%V zaW+2ZY-iyDY(hWErz`3fA@~PXvzO1i!f*WMrgbL@^FQ~}JY@R{?&zg9UGc4^wK5Yg z_v~aDD_O}#b}sSm65;wUw;HbhTp?UPr84$rzS7tqED(RbDsiS?XE@WZ%N*)y<_iDs z$Ma8?Q26=KWT=VK+mhkh=sOkcgu+2RgBA!4Pkbx*Gy zcY5tKr`L`-l{u>Y%XKouJv^C7F|IGS6XMoIvf<$``k>TRA(9CVaqHQ!*6%9BeGkf( z2+BTYuNr?fDYfqApC2hCd&|;<=X<$3VZ-Ns!KQDD5h=f#;^5b{i~vD%>0;nrqs^xT%>t4-N7@pV$X{H zRndhOraO41)DD{NCFS1B;~Rz-UO!!KA~eU#)Lr0GbGQU;e!P>s>vE}(D0?mB3vFPq zu^fNR^@t2i-u1|>;j5!1k%6V9Iz>i(bJtN%2Cd9VNIe+pQcp>p$giYQgQewHCeu=9 z(kgF1*baZQJKR-`x43QyI!)HyxOY|XSwq-e7j`~RL+ZJEqWzq_%ik*j*=zAGQ(=ig zG!6&K_DlG4zTLg|+i9WOr(@Pxa{fU4)|Y>sUm#YU-t{HX2qSAxJ_8*Cso#Y>C-!~( zMy7^z*?y-6Z^XULa&Q}CtL5-6!ZGh(SGH;O{T$pjJ=#6KTG^J86))w=YG6a_Izat& zdP{To#o_;V=6H|lPt(KfwZ$H@huS}tKx(a%ky;-dXg>jjYbYNuMhOhAdntSJ-Wq=p zu7(ptXgEQHriLKGILE*8Eahk4RzoWfQtjlu6(C%uVPqUk!^k)sE*AHnj|bEv0rcDo zM+!i?zTO_B>)qCk%m?a)vb)2zmiU#o-ZJe>1NC|bS{vjxpn!UZpi( zsSGB$g`zA0Ow#<;v?82@`Vj*t5r2Pv%*fFX9yCS$2xZy1-<~roPsDHhJoXdU`%!{i z{Pi)RLqFC_we$TS=GvQ-QZxQbxUqt}!kK#}Q_ZHMos_9gfC8vf--G-%lNFB+8a3@4 z>-yrdIAe&s?C5FvpcF5vj*{*S!q)%m8=kCIUa<{(>j_^v=e3GlYnV@6ark`BPVe@JPoCeuY(Me3wvXCV=P{Eo zP63DO%^$TNWPnF<2MlRj8kA{;y}1^!2Pl zzn&HO^)%$y)6!p0Q|^E3Sw((5s}KD3teCH-VZNT0`+AxZUr&?&>uHm|o;G~%am3f- zfgr=bo-F&0i`;kIWM9sD@_om_FNefRrF}W2s#*gpb>P7pP{bUPo2^ik`>A~QVXBtw zxaGR7SgYFnYgL!DR&}9kRZFf_4Y^hYoRc%GRc+*2)qdc$s%3xHs)kvsBG1UPU#nWY zRyDI;t6H>HMc3Xx6DK*2O|hO_0g20`go#mSOwJR_j8h~gxQK6guy=hS&tWKGJ%B?KNIZrA6mwoxWSta|`-y6C^ORES0?kPn6W# z_X+o$D_%H(5TrDuI)#*yML9eMOM9n)xAOKSh43LyPp%N?6c&fPeX+$D6&`1zTliR> zOW`TMY+?V<5PVQG6GB@!#I){AsG?WMa3&l>mSey*ns$F!=k&Kt^`cJ{CO29y^Vr1;X)GeE$zp&0$K!7^5l#>5NUR6)SRgl*>VnK7J*DzT4D}5E&jEf(fuhaWWSO?D`aa5! ziG7s1IkZrJh3SFex*qDch`F=xwS1S3?l@75czRW;O!4fybl7+4=)sT*7{5&?a2`XG zlEBdteO&BnrhCKB@rkv>U5zxcmIp=gHZ_i+*Ry{nB*2iqt98}Wa)&04)Xn!Q7atg> z&np-I^pyYD`p`b4q17MS+Ocz`p!_JrKH?l6hu4o(mu93MZ*ml&vXa*!m8sF%*^g}t zPo|rMv+1w*8b6N^+Xf+bz^#hH?dOR|6Rrvol}(k{DJ}2|g6jR>BdLmkJ}=O~=t~8| zkh_1-A1L%I?HhkhXjdy))yg)tl0~g-Pph`3EtTWRB6F({n&p*h^Xuf6$pmFkMin(m zIf2uhva0ud(LG^F;-in3`nI z`>lolWgqOr>OZDtD`8!7Ol&e%5^`hre58NpvRjU&h_=^I|6?Y;lw9t*+}IQ@`+E9x zQXP5~g$pmTvH;B$hunFfH?doyY*c^xmZGo@aY1)MN^IFZG*Ob}wo(nMPwaI&4d*1W9|CpK?EPT&X?swyd z(`nc%GgvY&Sw{cP&2O*!b9(=-4vl|V+Flp2&!G--=~|Kf=Vwj7t`+>cN9@);;th_J zi|Xxfluz8--r5)29|(;}(Vgv}qRstz{)74suG z*8FW|!2!qEie%-=cu=wl$*U?URhmOl;9BEStGGcU(@9nk&xxj!3_`+!--~|+_&kej zEV;fMFFqDi7s!>3h4W9c{Y#C`1HqoRKa*CsqRl_m>NY?ElT$sKfc3cmS#+1|Aa-X5 zvHg#!=~%-PfyyLcqjeD5EDmCu(?M+S4kFhSNw(oz(K}^Vqt~3r}^Nt+EmUR#t&OywOJ)XaV*kT8-}>9g$|eO zAg&+U$=F50sx>Ky4CxKFA&}5qwEG!ThVA@3!gtRw5?{@UAu)^>*v5ZCK-;NL^I8Zf zNj3s6^?jXA@C3Vf^yl0XJkfQ9iS=@Ka*{haNlY=3!>k`oIdYPS(yS@SNnBfu|D&pI zeJFQ|;^bRE@9AOlut_5)G3^YPy$`$oC|5B$*Ltat@fn&pLTBbBx%4%WmSofpd!F5F zikxS^emGX|qtWS;B{+Ynk_irUa)t-zeW|e6CY3j1FY#vVW!{Xv#GA2~c{BEE-i(y$ z=REv^m(%TDI^FJ>v+W+AZ1?iXcF&w__oae(n@w>ph(gDbO-2fbLWSC!P1w0Q>JZWu zIE1Vp)~Sb(^=wWdRE%sK)-ziw6zAW3+7!mb!Pi(}k<&@61g3x3))c3d&mZ)Zf@5GP z*k`sl5JxxHo1pOIvPdKHm>;tc`MP~XZHp78De9#YQ4C$~;6h17HBTQW)I5D&mB=@( z6W#80xZhox%|65KSIHmulS8Syo~pf%xbscRXkTO(%KtidAtlI{I#2-R7%I$JTfELrl zmq+j2e}gc6GgVXEwr3vOGW*`YrrP%+WRJu5ym+ODq#b{9yWByPB7f%pHK$!}yzv+@ zD{G46ZDF6wiE<>0T5~$oSV;p*S1&-3;P%>984-;nQERM5jkGV2c=duT0^FWki59)c zmzq{fv#9h^NZpm_Qq|IL8KYrB=%7TGn&vtGsT38WLE?pv(pj_}3ZJ8ezI}}cI`qy* z+EGpeGv|NqjZFX`h3DrIuIM_|6pq6=CL zJ0>)n=94Oto7*MXHM30)&H{U+`>A4cllZECP4BA)OI}a!vj%26w@c<#u6fWhDszcy z+?;?mR^2|_;y`O;QQgseQc20rIVl&$J01De3weKMDcT9hlI>Oa0Ab~G`VjY&Vsd_w zANDQSGX9$3R=d)P|bOb)(mg z%~(Jido>Dgu9SebJ{iZde^UIMEyr3}Oj0}Mgu~xuN4LlVptBnbRf491e5xC3+{^B2*PD@@L=@RlEPET8H(Mwk4KYasu!xAp76{-({-QxJ=T9qFnnJOb1UqpVLEC+Pw^F_DxmyT<2#xq_3bc) z8J=7nQs;&wUUiKc(O3m*V>kKbEn6($*m9yKtsrXB#>-p&RntPez@x418SB!I*-Z>~3GGTO z+NCX$0!Wf8IHmyBljQ(t3<~vkA{Ri_HOGjBR?ZC=s&3m5b=#vHff+-43<>QqG_=R0 zLVLo(`oRp=Ne`GNcd&W)xdVR=c7w0N90D%yP8Ly}Urr&a0DeAF7=OLmwd#)35R*@$Um4S5E%AJppPpjO8QwK^`S)oFqN zhCq40S{)nI;;uT)102^y$G)AD&BOdeMlb!S^Y!M8HgWnwh7lOTcgDuB>dGH~@nNy& zY2=T1E|6H@sG4OX7H^t@I{vMAFl#xBkRES^whfF(cDVZ;>V8Mt!r{goYAlT(q$?`G zRU3iaRjW&5VvWlJgo|A*iFXOk90$lwKsrDEwkd=q&LUCQmeZ|Q1Ud^y!@EPIGZE~( zewa+AE-vCZuH{2f&x|N&L)S5X8p;%zBc6t0sxc`wy)@EWmIWjJtOls> z=#PkuI-(L^hD;Wh8DGNP9S(PQ^lAdCjBAqF{-_x6wnd<#(Qrr>Lu!M6;iue1jOt{; zpaYcc+=FR+P%MmoRufDsq|W-SHVifdK;g@0yKxjGfmWx;SymUvXLXqh4ywTcTyH8>d6J~$kQTK~_>k2U0TZ~He(A*lW9 zS!~x2g1r7=7$h0|VSjM-c7tEM{jK+h!%udDKMZd#%kVvW4-E;Z zh>dVTlh)A@lZ|nI@$r(MQ*p5$Off#^y#ARw;Shg5%_QR2Z&yZtKF%uQ&r~C)88-G7 zP~?RrhAhLSqIN9M5$WyETMo6qr7jru7Q*mb70~~CEkjVOIZIe|v!xso{B!vFc5;S! zT%fTo3gl9^y}8Gvg<#8dmg1({h4?;BrUg?Wha;Q!w-Op`$yF zHyFRE%PqTq(FMAx%d`Eawx^#`Np^U_kmfk`Xk?}1JCCe%Y+`Js(?dsAI(470(y67f zmCm>9$P449F3%oW>10$2w{U!EaAAyx=#AUp%ec;r!54Bh^zcjR4b9+-=?${?<@8z_ zenI_Pc4I4zni@QN@MZO;o)4wCzBF;++_{{7xY?S2-Rns1U;jE+SN!iPS6m>Ke7?Kl z>8|)FzrTQ_2ZKeQu+H_PA+3Zjd)okIwceLXS`T;3+ZZToGL3ti1Z7?RvSAE5*fvH+ z>2DsRUiY_Z#Tbb*pPwPnc0Xb{HX5j6L}@%hvuf9e|SiYCb~R-5EqG{$#v~)VhKsEkDz*7Z>fu|+snuS zWWtuK;Ka9qT?+Q&*qjXx|64TKTOt0<>{NEi%v1o$INceTq2O<#26v$gUh$#Z=J6i8 z%^SO2hFaz{j>KQ1ETnHv^FGHD1o`I}iv={Nv{S6*Ku;nqhgyrZ9C+8PmZSd~Z8=?k zb0IS#>MLd5HEX7&%MxinpS2u5Eip&g1|JI3;9Fg$lm_gk>vxlW1LnQcAYWbne9f|O z_Uu!I7qYm|*)nR1{Lxe9{Lvrr`J+ERM*gTDbRn}NWFDy>Z~<8oGM_XIw$RxUkXIT6 zTIj3^onIOTS?KHuNGA;fEFg>0GhNny9Kl;(4{^BpW(odKWeL7cRp9t>vjqM}%|=t1 z_%)X?7=7kKWR;6$v)O~uhc84~xwtl$Nf<8$Ld2DgaW8ZP)L&{8YE26x42$n97#e3L z!o%IBiSa~x0=b50>bT%SIMTu}-zBmo3=WIIWd=wCi&|G_}&hHhL9fcqJoWDA%Nhpu{w0`~HOFm~-0KyRvFUhUb z!v!NCUXlx!-+l8tljgyJ#;TWIz1%|Sl?qz}(H=TqBo4`xi1yI=9SWc0)6JH1R7u-J zCm)jce#OS}d;S1vlMo?&d1%*vS!1Ll)Ubf7CXwePo_Z8F9iz=T3^j=|uMl=xn0)(Xs%gUgJ84N3ksv@MY zj9X<8!c#_B@28pA^1L=*Eiyo^k}fX>r$V0^gHus(6XP^5!A%53B_W!B*MOSgdTh;Fr+S^zl*F@H3*9> zJsg%E3`-A%r5XUkUQtS2SLv5EpsCHF-(HzxgL#U>I@AeD{)vhjLP=!tz zMm2qB;wjZy+hh+R{fZ7DogzpNrup^mqeyIJh^CMQl$JGR5ZSSeYBbyBNWxdl*hys<7jJYzJ7$78Y33f#Foay7v$(`YQEefcXNu| zr5CJ(B>W4Mz~oQ*gBGK`{f|&nlcGHSo|!+)>^WO*wi=2GVKkZ$Mq46;@rHu=^zzWY zth1LxQP_2s=|W<;(C~ssu%}DB;6AoJ5>aCg&6qK(_fh|Ua=G)A&&?U?I{f>wh2t#) z#Em(lS4S%6y<3JcpKF);is$DHGWGX!uWBH3Mj?~j2p#GH_t?cgoHJO*YN2Da2xVwa z&u;`%$7TUO3^ramYEn*DSO1klgBi&k!bTY(2Zv3Am=i7kjMPo($F!tF+-=Je!ksU zM_xM4I*b_cjIw#qOMxVzk#z)lsbpsw>(Dyw>L`HLQD!{v$)g{P_PdQ?44pXQ!3G7Q zI726nawy_cU?M4E9c^|6yo8FMzuzl<=3eo0SMm#gQt~snE$$T&76-pOusX6Xz}`UI z4Z_?2ybZ$I0Q24T4mEm*2Dc@>BGED7-M0wO1+ZKY$AvIl2)~7}TSz0*+*KjBfe06fFo6gUh_HYN2Z%6$rDNxjdYY3Lw(3MS`3kDvAMa4Vdtqzd z!q&Tg%Y2 zZpX`{9?vhpclG!YFe{UKd|nhz!&-nvV{O$ivxYS$IhY1c3}_f=%Z5W5u^_Qa_vkf$ zhyz+(O^zDo@pX|1E+=*h-`j{?X06B?ihUiEf&&ACE`ShuoZAp!76xzBb;@CuUb$q1 zMSpD75qjiw8MFo!g6CGGgn+@t_jWEyPVj_0t#@-_n!&X^;~ecY2DL~ zMCO;Rg2&BBv0fYdots`gGs-%xd!;dd;3)%}R`Z@v*T9$B&6?3~spHnPm?eWDF|iCU z2d^lM`XPxPGn_G4RhMIqkp+(tEr<;uf)N->`J2p^;Y&#&ZRDqQ`&$V8Aq+WL&gHhq zJ+*XGOvH(7$|9fLa*+!lAVb_?kzZ>00t_~A!h}sgEI^2uzy_IGL^e{wDD#ki@6}>! zw0Q35D$)`Zt=oCBF@J^#pq5T9>4{b&eO?w0hm(=Eqlchr6TUkt&_=iB_=47y79 zlx(wq$I^TMfpN%dwB7CnKDq*bpR;-0(T0wmd+zchYVNtqkH&M4x^|ga?l63goO|y4 zi}Bo}dNb%M-BYs7%iMG4Ul4N-UZd@HF9=c=1i8Vw=g$#0Soi!9b%S-!n?tU@?osXb zhR?sRKRf$Ycl~vbxMs*UO}{hax#xQ8o;QbFf88Ul(RRBR5Gf0Y++f{*Yv{S>=snjB z*1d+Bdyd|7U2olM$hqgJJ=gWuy@s57j@)xyZ{2Iix#y^T)%DlChMIdlyCo8iqoYsO z;-kRlOkarf?Oup91?RXoNl0XvcN!;gXWp5d0+9_*=HN-aBQD)W|92ERZg^Iwe_@!X zjL+@BZoS`8@y_8Hp8f@YXZd?!RNca;v0s3{h3jsCzlG~Ag}w#+i?P1|zlF<2iM|Eg z*RclyzXkY|u{Qy~1>qN*|8Dgl?n%&5TFV z_hsl`80INszXsT?_sxul&G&KWUvQSc7hY8^yc+pU_*-z?8ENEyL!ocMac3mYpMu|l z!)GMlw}Rh-W6nsPp9Q}KhfhGh&xO8)zU8CgsP{U7spz&8IU!%;F>)xdKhPdT7+G> zb_9Tt7A(NNa=n0mLzC`mYXbaqx(!@Nm-@Na2~42%(=CJkticp^OZu<67D0Cc9;EAI z$aC&>1?B1r&|oJ~L67neXsd1{%Cfxo9AF z9z=Xul=^aiI-Q75@2s4lh9*T6JUva=G6kOgCid_@|M#K&zyHoEY zA-?l5>i0dDu_Cc=F0$bx}( zyl?iFuJPX4TPCS(()HKAhMs@U-$Qc?UtHHc5}H5WUl;v#&oH%NWL;Q0-e<3?*LbhJ zu9BmDbnYsfNt-3O!j$yLNHa(_>PB! zZw2J}@Vw1~*9&S%_c2){pxH^dL>`lPT=x(L3t}_INtml@nDo)9o}9-b z?7F`j^5|`h$sL~d$vKUI+gkxaK0g2RK=f%uJcgKD4CI$Eh!7s-;c-dqwe!3NP>KM5 z@rQhUhV%Rg0f$>v+{5=W7%Z*;z25Vf>i%>@P*e0BjC~LWm&<$)eSd@pqE9CJF~H

I*N&}MYI!Vf->(tsGpdS#^t~SV9f+{&{ujfew|7kWL5t2g zW1k3q3ywSQ`2GAxK;1U;^9G@;$ ze>_E%UAUks3CCAStni>QVGspYy~|9c{>Q^69;^PxkxTvE^QcGR%g257-rj%PVT+?z z^UdWM*YCCeT7%pniO(f}@!|cvA%LEeJm$j`7|kaQ_{;|!Ry1&x<~1Lt)o6ZcKx01O zt^%-J$a9X{)tCWc$7LHaBgTyi-5b{I+%#Y&7H2ua-g+Xiy2*#FY)*Uao z0*fMxXX03PN2{s8sK_ylvXr``rBYy5lz$?ixMbrA$VR1o#knAlG}l+fAj(rtoYu1K zsR3l%sBbBHkLth#xsY3WA-D8OW~m0iI&O*O&62CQMXNYPZAdLdc1i!0TFO*QmTJKg zzZJH5#Y%-gBWaC)Qm#;ld5>E%q?Kv>X+*=g_>kP4!S1efUrp&gB#V{A2$rgho|&C4 zh?Ob*#j`4rmwNI1qXSxuhD|kXK80wgeGKS2fT%F~Inas@(>iAKq8T}6PA^&^$egNo zoO6(-mGNE(I;yOd;W@l_l4ouehbS#8_?#&{{NWXQANk3D9wP1?EoD2c!f#tp;a8aK ziZs(IbDHTN51-=GO#gUjKdoQ?_mY(oiIc!2xDKRrgnRS$9jR46!i{;56+xe# zqLIvjBQs;|^X1k*L?2JYBTSj`J(+WBV{Do6U5Fihz#ieujPJ0N#F~jnv*BGb%8bI8 zX%S`&zHB%{WNcY=WSM{}8*%s|G}#zQrb3Y+1lfop7$L{1p~lWeyjZgwdhGf_lOV{x z+z5hy3}~mSG=v!~ZALp7pvsVbwyYP-=w=s0nL*uHoD&VVz0b(U%58B!JJ(KSoR zk~b|9?H+HK7-e>kNra{v`G8$mHW(=Mg3>l5!pjtVAramMQ!2=20WFl=ZE3kr`i>KC zDC_|(5X_W)Zm|$8vL>D-k=cl6^nI=2hT`YmroT8X=BhSDu{~!*Qy)6 ze##C!=^ore8yCKQyE&k~dHd!L?)v}UdnLU)Ka9KcqTAbkdvCvf2ldl$Z#DSA;l0d% z;Kj_)5QjgQVYTzXw1)=XdKw@7qhfHOIvY|OTnMjoEezpp;;|ZkPKDJtzWVsDd$KTb zM~9(yWW;Yfe%kTdxL2ZH+TKz>K7DD#TT+HTeRaemiGe+Tf$YZkGXe74hUJkDr=Iq3 zxV-!~i6K5*H>%(Eo9*96{FZwBl0F=Nl8@K?R_JcODek9(X8mVK?Q&)q^;c^%MJJZ9 z*X#uRQDFjF?ew$i$!C+No>fmg{+Cue>1^DTvwWNC>1N|5n-#`LJ<+~bm}p!2$PEf) zP(ck-m$Rjv13JvPW$J6lsuHHI283fRE(VG&pPmmOuTX8`gw>Y!ve-d8(Us1BY{m*cWa?&cs$77t%=2 zuWNMMN>kPbNxv%toekT0*T&#kql1N+I%Cm1RD|H3^M~Ntq!`?yz&6Y1koswQu$%fMWvl_s4BV!z2fTnlmUko=#~2 zLfqa2!NSdC!NQRl=wIwbcBh?;quPlut}RC0jKAS~&~adzEeG`?Rs;Z*;|7UX9RSvZ znNZY9!EV%lRS8~p{kX|VE#UfZkT&gyB^HhD9{(|eHfUOHu3Jl5tAOo)*3t?0^w{zH z&*r+;_8pTc0*r1fQ_*U!$|Fh-gYpO-06~J0`vnh{4w1@K||N9_mkr*6qnG{Le1|Cn5+!YgX2GT{Ouo6&DvR8qm zG8vLEJ6NRE#B7lb(vf0xnk=gINJ*SzvV%Y<7o1TEhto#qVAx=PojP%#z)&~wRDxwF zM_ii^jPBw&8Ou-swi7b}{+>A-^!F4%_TTS-?70K7`@|q5djrbNp}ZW-$)S84$i?A2 z97#rkh)56_2Ik^`6eLjO3ek^1aVsor1&K!hxyBCW_F!HQ=Jaqr59RW_Jl=@%gVvYY zgFG+F@xuHr%I%_mye=TO0mL?t)CN)7aHK|R7E(aca(OlqO5=!*lq{rY>%lxR%n`%< zFw70ZyfB(eBet1AMiOBL5MBUb1rSaEVFUnC5+F%}Vtgt_+kr?A6ySs6dufce14SGt zwuf*B7;}K|1{iCAa0VD-kP}~UnCrWPD&2f5kN&O#6Y$Z0_TL}xl}Go=qr8+|I|4hApo_<|6A5~Fk`|u8PQ)_k>;$li z08|k`DiT122dDVJ6q@^oqsZE`Kq?+YB><>+@DvZ6Vi~wt_N^r5Etv@}ig}A=hVl;8 zkjhMChl^l;-WndZArMOdVF>^%0emF@ulTtQD6s~|avmhe0|a?+AP)@WL4hp09Lp>x z=$i(!$__?|_~MwH!WPBj5t$s5gg|6}awsFP(*fc;ArVAx2AAPM7muHeV;SMVRrVtLP{@}zprPxq;ZVc4!+qjB=Ho)0 zV*6yohFME+=MykdFR#fF2+|A8q|6Jp~w$bzb_jDICP0qhZ&X~kcPYXmWWY|;4RJO`*d&C*AqSqfOF7P^TXvf(8k zkphm>+TVv9lma)r(8Vfvy9?dwax7MglW^!^JMf^LbZiRT^1`>fg=#}eIyMDwc?;cJ zBpsSUoi?pU72wQkexLLBWP=Y+!N>5xBY61nDfkE;eEbeRK!rPT3B@k1y_446N$ci+ zti%yltCzR(FSlTD>59JshRgh}8MT%RsBsjQZJCcy$gEXh%Bu{ey=>Mw7b)2iROOtv z!nv$CO;{0dUc+U6*YXw5301y46Wff5P?tJN+DN{QNJ12smL;eLUHei;7- znw10(yuw;^(SMhf=y@5!Vi8I}A+rQDHa|;1lSC8nIg~THJA!z-14g;?ubs$$@w{uj z8;+Z0=am_rFZ$Pde?2xmEOb1#=3l#E&gk>onmuU>LtpKieMuYG0Q2|9ZAV@+^q?Z% zg7ZD@(4eE{h&wjts5x>%Z8~y}9HF9)oFnesG%-hrYj-|_Q4RXpie^um*w9xmvx6F( z!Nt+A_UFTnyk_Xh0(lNxe|_?QZgKtf$$JIYU!VL}aJ}`(H;n78Pu|>f{q<>NvOE8Q zXR7{yk4$XntC!i)kaN!s);)iIx!$@*T{HA#fxHN=zwR~E+;imK==$qkL(V-%?v1Xu z?lt7xBd*=~5H*I)Mr)?E43T zWU&VCTsR@W!uQGe6-H*Df6+frQAK#eqcT~DH#jhpg?@u$GgT-!JUrid8V0864<#_K z;eIndkb_|~>`;#m=inEHn^HXA!|1q<=InJrLv!}JSi(7bS#05)y{wFI&ca{Zc_4E0 zK#ZIM{4L<$j2#60EihAmXyh#5ZvjtLfgi$mALw87dpmwv-<2R<)^jKbqk3+|cwl_z z;?5gUoHxREQoyho?l)tH1xydaPN9+W0)An*sVeYE`0fn)794Yy@m(AEEjZ>Z4e8L$KQ-P%gf^&x+2~)dJtiJ>3yg+*ylWX&v;VY z^VaT@|F9>;gQ!-xH+nifD8L9$l(C<}Cc+cy*|=H%KL4I4;adQ=h?bM)o0F|3es4>s zuz{05ZvUNEej6))c@0^~c5|ZLR)K5CTLUZE)~nD@p*h{;ytOsGjIE+{A?sihKL=gPp6deE!6so2zSB}v7qkw4N(W57a<6&JBFA|SW31?y9EgaW zWBe;RumWQ7=NLD*3#4++(OQRsIAreQoKgU>Mv!T`MiD`98|D~RD0Ovg5JcD=ZtxO^ zi!OZ2G(#F}WQUFHu#p=))=${}4W3s|^3bgCyn4dZV1*|S^Sv+=m6`cZ_)`K6YNCfP zRZ1g&VL>0OWK7ojF=dfZ(7D4k#uit;rYZ(MmsAWEi~wGWH8n#;56g0aC_57#QPe3m znd=n)NKrHX=xQzQ1yRM|03t?LZqb#4LwFcn!NpyF#8reXR7M`k57COYv_kSwdWcrGrInJ0 zvO|Zfd8=vmN3839zN(~uWKbpLbC`Ek(z_~YvMQ;+aqtRvjDjPlP&(R5c(prD&5v@r?DWkvy#RX81QRlHh% z9;*h)s-60qm!zDxkmyu?q>xDkG41q0HJy*lK@3PekkF_N5;Ng7>!XM|0TDRvDD6pc z{aQA{V=e#((FUWgi1!fhqc9vlHp&Bie+RmWi~ju)VARlz*rI zC@IQLiL|XU)mat_%zU(eE(dL^islbeo9RFUag>*d)Mh$>Ksx1RlSXZSrlEh8Y5oZM z*HRutL29!II+{sniAvJM#L)OmMr{^KWu)Z((b2>d^1-Gp_|IQ_SfB{|HVa^*s|70+ zVK;HbhY3<(Icg7?YP%=Cvg}eYRYnyoN7)=xDCYRW%FC7zYi|VPrUAl%k4Xlre#ja6Zc_#=O&CY zzY!_(8&N)@5#=Lf$rE??oeTKRPL@t_YPkpnMZQ`tLPHDg=Hxs3%$htaxFM;Ku^a3+ zJ8A=YbGTdBYsB5ctl*T_xKMR%-~>yYtRvD-MI?aq>LoU4(ybSN*k75kK}-bVTfI}? zxtW$cs>x1Z*4%yPG~ymN1K;@x)1fpx%}&G9>@+j*ogHR~S@8@pE1n@{7418B`AI99 zB-V;I-+7(Ob{xcFa9mqodmRF&Wi{B3hGO>~jXu z8Z6+Z5;9~2JsAN*K;wZ#JY~^BM!d5Wia`;H(Tmu+BoSMGnI1$dSeO`ztbyXmM-Ot3 zP?m^L1`8J^ig+p`l;5jRf8-Y~oQvpKqticJJ{+ZODsWR;~+=hfLg_$~V$a2u*sox?ZZG2F-SWB(xX{oi0P9j-KyG2E=Te>A&tJo2K)I}YRB;mMT zg(MuLLn77l0t1TC0XSkUDlNX4ec5oTftFRcYtfB=urT!U1E}-Yish|eZkD2?K%#wO zrLZQeQdlM`lg#Q&lPJ{Xr#Gpk&eY^rXSx?iOak(#!#aTK6UBP33}tDPmCvyhG%LAe4-jVJ)GYCpV2mbdd)=cjC|B4V5polR4X)8jvlHf zJX8&T9;yZp)e}8bj%w@)57mkvs+D4>UMkR>#6hPTUM_0I%aE;tkW2#psYxzzdak7m z7ACI6sHHJgUkV93SE8J1mA#zGya^d?V#}#akdl${rHhQOZDf4So{?nFNae@)LO?gF zXJmX`CQr7>_&QIX%+px9XnB!f$pm3J4PO#}bj5Fk!>gF?3QNgZUi5RS@*+daz!+LH z#?bmCK%N9>oETaX<-;n(7+Nuh6xT7dJ`X9+gJfWinlaJ(7W_$ZqnArOcs1?ccFX>M z)BNFEbGo|h|C9Zx+4tL}M!)}S=BM5-xeoMS#On;(*TH?nhXIVmJzjcy)K&JCLd-vZ z7`6mVMV_j!K;~$`hKk_1sUL#2m`m6P!x;_ViFZi z@wJXAtd>_5u(i^}u85o6CPdBoqLxj~sCiZ5>2hq^(Q!4$4Tb62fK50vtLC~&zmteX zqvI|O-+Q--QHqWZUgYY{iJ~;h_kYZP?EC*c$@l;J-S?lp@8A0;Syc7*Oj^Qs3v`VS zk0BL-k(})gDdfJ59W9*;%BN826w)Zh`oyqk3hEN0f+?s+jJ1eisT9y4Hano}yrZR{ z-{uP0r#&qzz4hdT`*R3OTkn>1sn?#EOK0f*4#*&blDFM>E}e1uK#O?`WSF;qpcD_5 z;h`ctuH;kAl60x4p^NXJwl!AV|@EgfQa}_un(rWd8nm4{_f^+<8OX{vVmz zM(^B7&J|9co0X~F^9t2FUIyDcl@fD`sbb_S7o;Tu1t@r&K;bmQFShw+rzUpQ>`9Ro$H;p>8@?j+#Z~~X_JCk;+?|j%X@Zi z(`ESHxg5@ImP_C~3(?#Lb=2N@9nEcUd2n`TtIy%wW_yh*&j)9EIJbHB1<-hE<@w;m z59cSzdcSs#WrsqzIV79QwasPM1n+#@;(psagguHspF#6`&3s3Ri zL!Mpd16M$|&SwhIUZWQlXV>_S02328>t266OmxSX&sge~)>_dYUXsmUM4{K6H02g2t8N zY%z1YgcazZqUUUXUh<*tVtaJez=ki~St9EF7SEq^qqyPAG%Ca-Flz|hG8K+!u}ryT z^vx}!QEqWj{FyF*qY}Vy(ggz@T|g;y0XK1%yAiN z8GQ+R@p5T3FM+ByTwfQiFEv-%Gb7nSKy#(5>hoGo$2bvxG9zNCBHDa>7@>Rw(V*lm z6Cm|`YGO`X%n6h^HcIE6b(Pm^`n{n$>hSr#!A2>l#}Nx|Uh2^1fI-P7BBZ;#2eD^GQR9 zZ^HvDM|+O|*5SZtdbtP=lqTy!sH7t}@a1TiVu3t=98iq|V*2}*0P0~tYkkv~$|>E! zJ$GC|5!;RA3)DVIB&}P=bCy;#>vI2n7Go33MGqBhDPLLk++{ zlXH^a8D8jDmGg%TWkPWNFfz$G$lj^5tz15k%fxbd(}79i^N8(xNslj>kgj2`UJ|?u z=Aw~*29}*H(#n&7eZeMD z5gxyC;rcl!^X-{GR1>3L|~$0pO4u>pUf8RP=u2aIJN4~r^^szd@LIQhLKZ$ zCI}0{wlF%|3&NHOOX=0$Y%unlk1^$dR_BOHwQky&)bC}8w%74Q+n-`$QZ#ca$(l+r zrjl%_Bvb190EJ(;x(HTWk`b3+!zGz;2^L%hGxv?f74MAQHxsYaFnI$WZ@}VBb9e&= zZ^YkyXYck43#fTTb9d9s-864E$=VHnIJ<)wy8&M}VC$y2x&c!+;_1G#bkoHsqd7xx zbkhvoG(R`V&JDP^i!gJaI`ziR-CF^(q(-J@>i#|*Q}@LH3`;G;P|L8>GR(9LEA2hS z;iottvnFQM#EhDlO%pR|1~YrV!!+BS@jEZ$_bsRIf|$S=7I20E9J7Bj%--U}U zn<%a-#Q2TbzA@7`X8DE;-weBVB(pce>di2EV>WMw$(v*GzB72^LcrV%-k7}`Gk0Uw zZphfpuyw1Ly1yk(!h z=7iC(6S;vCStb$9$WDqH?R{<0NSj;>Ge+822!zsNjUoyS>!N9F5QFM0@kfgoT2~|7 zh6)XrEURk_u%ouP2^gz0vQ_6}st#ePo;QbfQNr-(EieHr-gX($;#Q4?X3^6zY#0E5 z445#;UxToXr8zJJ*ViF`7(9!R2MxMF+&Bx-?}BJc9DY^EfdWehSW*$$m08!A$rguD zVW3SBm$xXa2WlTcn2@=^L@X@|ufoC$@ZG>E8 zCV$wCG$#M&EQJh=OaUXczz8%jvJP5tgCMm8jpDI5&JAKGeo3mx@h)qe_r|*`}Zuj!;6D!$7Y6b+rwYo zvGKu0bG+Jra!^gn2tO>~&B+=D5@$ZDa~anyaM*IPUSag!C#!hx-M5KvucN8^cl*Q8 zyL$+2z6|a0;r_*c-rZVJg-hSxnp}Bv=``G{L!&G2C9e0p_H;FTiQYB;4KFinMNF6e z+&n!wqo{%?so;UXML7ksqC+XAzz8XLRRE%h0#WVIv$1EzzJ>Rr1>&-7obL7qm0UXp z6UASW`cv$9sfdI^CRDq(uudtd@MStlg^?k^Px=#v#BU9McN+>n=IuEY%*)$(NSN7h z|4Br{LtLH$mC2xu88jw9$RmWYjKE}&#lU|3y?`Et-n0ON-EWB=>|RS$X4Ko?=%4gj zijjdB?07Wr<2u>s#|%12zs=wmv_<;!9!y++KWu2bW|DzWNk9an?FB?~h~z^s%2Oxv z0MYin@!8XVtCP2vf{DVdaHD;7{F1%tNH52uJ+@c~HH<6^?He$j2aRx!vd}i{tzQ#COv+)P35CTMw0gyCIJjI|6iYGC_}6d8 za>zv(Lk1ASUc@mv`YNa^PYxvM$XrTC<$kCkqno6EZzi5ZPA+|S=nPdiaSE?|B35U! zfWJ;blnrM@^r$j7$G$exV!{_v9t;ql+|CO;!GjDIgXkncCS`-C-=5J9^vs_&R=+*t zjLtK40ysR2&NV!vWaQy4oF00n_P-VNoWmdKjK=elEg^VLR4L9hQVxyOlQdEd9;qjK zq#PQ5sTDU;4v*A|9;t?Y4j8EwKT=P4q!j6mCOawo=D|dZFPncn1c@*EQp>&;vo9}f zJbhu~=@I*K#W%{Z<*P@Vk-)SsNo_`&)W(xqHX|)&L-nkJ7Pg_x>=}))yEl^l$Zfre z;|-HP^y7)>{d-4@Kz1SQ_{jKZ@Tds)Y4k#WqU15{a>4R`NIWMy{;#Qm<+RQ)ElNyl z-x^Z<){yeKw9@W}C3~TTm<`ZvrnR395PvqL&aeTZ#0J05OysEz9%OQW;MkLMjhaoWPsjglLO-mI|$8!)kktul+vUv(CnB|Q@47KeEA-d0&e z@k7%E0sa;8&j<6;XCy>Fwj^1Fa1Ipo97sX?&R6t(mOwe<9~R0CjYQoXh0w6tha z63I()LfR$$S9&s9dMa8p5iLCpEj4gEa*Txu__K;w@>v%BfKK?^zW?9kcXPOIRKM*v z+rN+6DT&7~k+M4Rc+GEx?)IDFemZE@e}>d9XNFOKwKh|9VhMZAPQf2lrl8e-PC%=k zel~gXS@qQ8e`&ST&c;nT%eSeXa5iqbSz(OSQ|)^tX?Q^QVL6tX02w<#LxiN`>kF`5 zxp1P%`er0Du0m7fV93bD`w>%ke*0V|x|gEbi6$;%QB&P!6SQEkWLrTrMerM@zwSO4 zRx}k+Hd9zF85&?yBNstxX*`pEsYN449sA0^Z8jkxg}g%V>pC4z61$Jb(6HOIahou& zBo!x;sW%qQMg#c$%6=G2#%EB3OW_+ZOq;+%N#`K6J}I~ zeNTG5^Q2b_Q*++Z@pnh#A5xcIz?Au4yF4}eA}#yKLpQqlCLiao%j9(_I$N5po*Ix) zLq2QEk;k;TSj-*f|F%I~-VEtFL%%KORM+uv8{+~7Cyo_3oMyOxQPK>niI5v5Iq^#Z z4x(+#LIRdm((`;moL`Dvo6673EJabN@b+!kksCBEVIMcze=N{q^W=P zs(6i3;)YJ_dg*$9E>lW#7?|TvM?40`v-2UnGyLQE-XR--Icv0gyava=hjJSn?m9il z<2X3{(d*~w>3h1X+tJIOTOPgdZrQtK2*bE`sW*AfrXc^m8^8N8*GP4KOSOLttvH1|K_@x@ab)qT$4=C%lSx->j9DE z9ygo?ETm-9@Dw}4;<5!%zz;--8_EGONUKBcUN)VsNu{GSI!dAQ>2po$T$45jToRF~ z@=KaLIz>)@@lDJwHGcBSb)}6Ro8$xzKbh8D$aR3d|L#KadCK2+`clVEn>a@)*-mOd zA|2ZW*;Ci4E<9WAyqnm3yc?QwAr)|r8V^YG4sUeq7>qii`MuvcwlmU?rPR6O=k$`W ztfv1V_EdC<8sGV)%t8EYD~XSx(Dd;t$t1pIS+?PSSGeV(f`I0n9}|JL9GK+VOGs;u zt}tqC4dMd7b`>zWgTZ0n-E6ryDop^-4h}1h%s5Y#XGkYCTj#>yqz(WHokh6270~_Vdu^L@t%=HvM~0a{iPx zCe!5o#`{L=C(Fl)=o=Iy=)Z1A(!HO(C+*87-^mfJpM)PTKlzjK_!(0q<^P&2DK8cn z%T>y9-KY_>Vn)ol$rY|2x9(l8%+@5~#X|CsWIR(dos^V+e>L+TQ^?_+)~ioOc`eO< z`H)3iGB%#+!e}x}_s65lz7|e%Ylm{Z7FRiTO28T8eHqbI+eOeQi*Z7^lQ05{Mt6_@ zm_Zvt`k&~Q0(COJN66|guA!E}v-!+^w`}BFMqL{{w~?PinO*>!KxDsspGI+$G&U%h zCBBtv^2jqGX7+^7QhAJAgy|aH{0Sac7cs6ef8RqB{yn&e??Lf-JI!JhPaHqvM1R#< z+o>vyKS;g(oD=;YwcdV0mWe{V8Cetk)5;)7+%bkBj{)2<40Q}kD>Dh-j*=QUn)_V{l65gU|6$4AZrnuowo73}RUex&ZbK088r8azul8 zavFkMo+?wFlmX{tQkh65$q)fbwiFa+lLqKY=fdd9$VP0UWg~tC>qgvhICmV5R>xww zr${kIgczQ<#t;QVkYIQO7?Eg1e)_=O#pB3JprMr6k1X}~|BAXWPqI)Fu&~J4~c3HHBf8Pq& zRDije=9hc!hyCZiO*7`F`{nS*y??N+?)jg3&2oJmzCPFL;CFldPlwiEUu*aX-md?T zy?0xZ+(y!MUnMW6cM#Sf5uqsEEvd|8raQetW|pMVAxqu8*32;C>33xuNy2}3$AKhL z3Da~>D;Xp~92^dZE$q;s1-U#e&==UmFVL;IY&Sbwu3HzV#L{8K z9;;6BfoVaIy#v#NV8%_$r{Xx2io+-fStdtkrYyIYdK#jhhFI-1L;)Fx9ahYW#3zM` zZ$;-CZh675mvnH~yeXwmOMKnD$+`0+2b!%5>AuiY2D5rpc3>7#f7VO9MAy@EAS&w$ z5@0g}QC8qM`H0R23+0Im!!}I z)w#x*g0w1^q{tPg#`U*FXpL08m~23E>3h=;^0NYI#jfP+zyJlUxfQx<_8(#mjSW4Y z^#=(yAlpxq>6f16e^*gjos=y|XH@xrA5SWDb{!cuVKV{gG%HD+OQ*~wsB%F@7|6}j zDRJde;i@Ptn7yja_PdUe9)bUQNDu@{U2i@6FFo@wJnN6oC<{$ZO3(CLBg+pYU-H?0 zk;z`#luIrhP0rF3-!e|sQ+*5fcx^_W|Jv{*zKCo=eJUcHe^{ALF6D3P(?WxXo0rJB zxTYz*>GjHE99mzh-ax}a=7bas7I%{E45uL~M8KzLDnj}6W9^O1mIDY?vH|FX0E zZe<+dDQTvte{DSQA|`O!Y0^%|KHHFh#xY8j$u9DKZn+mUe&P5GsjiupA9E@1sMtm# zYZ7(d2+#E@5{%PfRS4y1q`H^x;hsOPX|W1zLn3s=I^3Y|VB5&Mfvd~LO7%j$Us0FC zKCB)i)1j#7zxNLj3vStRP{Y%N09BXDwquZ&B2+bqf3eBZIWg|jahNqoZ#5zZls$o) z5u3_mS|J(MiBLMsjfEWAmW9D#eyd2RWis<1vR4CCT`oJ1F65Yk7sA|A3A}Y zLiAx6!dAl;RzM0@&)Q>#YXNY;4ucB?i@-pZtr|i&Vhz5h48A3@_vXd7zH^ANHo33} z%|NHfe?-fHcGRYft)0`yriDeZK-bRWgEDwFC`;3-R701_-J21zxy!8Y_rn>o8JofP z!^1X+u{n`vo9ees6~W9T`NvF>hNcQ*Z&j18>80$siHsecHP;+Mv*ni2&wGn3%2~AQ zhVLWg^pNf)(!7Lvmx0->SmTmZCj=sb2c%etf3sboHX;%+JObszm~7VuUZB+u4^TG~ zYGy*cOstj3WWQooE!4+^w;VzB(glX)g`aL1g790(vOwi9k0KYGxw6OgMnS z-w#610bT6~2Da&GOV9&@Vt_><)94RC0u*K4BQ0L2!;3U{)mitKpOdPoNAPrWq?J@n ze;<*B?++Z5g@1jwXP2(IXW?1+bT#!@&lhU>LLFbI;S2S9v34)|5PZuKRIguPSYG() z2G*9;9f(F0`bm#;eSxMg)bmAJzDUOxY4`&DUZ&@Ou66_i+w`<0=z&2oz@i+m&M?#p zPdN=Vl!8Ut;7AvomS#TCD$uIaL1X1ke<0yZd;mTjxyGdaquhyA9g_H>cNMMRyyd@4XiDxI}nX1^b^oSQBUl_Kq9&u>gxOe z27f;YJqL8PBN*7Gr!7Gb42l63W%g4>r)lo6IW39vA2B*F@#i^O^h1WDMeVoUe|%?) zNEv2Yvzb=rHDr>$A=CY79pLw?2;TG|XM0F#Z0<}?b4WkSQ;vFf;MpA1_GDk9dsR;k z0Vln4aOxh(eZ)PdJ*m(D5*s`0#- z`;k?HPd%&H2$wkp_=7+-5Qqi>f6YK58Hi^WS%QH~FA#1&vQ5T)TIyLwzSiZ=$Wsoa zzRHdvew46O1A%CeaJG@B7{~+zc>o+)Lk7<{;xCfVI!Z_w_9&-trkp3q2oxEfAQN?_ zd>BFkXK~rL@~3T0pLoW+;ZE|wGv?z+8a$AjcE+5m?p~tN2qYSTLL(4pe+2rBM4sV4 zTs(u$HW~Uf;md?-6VITlIwR~bs=nGMoJkjGG(3rhr_dyvNf-MtI{=QXAu~N^1$)-K z8rHrZq*L|+2`sM`$S?xLlfQWC7bj+L;4~D1wL!*lKo)-lKUmTR0S})<8d+=-)W{Gn z$7+L&BdR<>OrnSJaEe12VyW%L~-f1-0Z%{iRptox4Os>-bQC4(r0G41ZvmY#=oY^QgS(32 zmbvvFwcirmEIB$%L`Tk~1DPj4WvTQhOQw!lm~m*yJSR)+&|4}|%2FXAOXnL}I=ReJ z4*=f8yn)!%sR@lsWeuV zNycSg&{-W!N(Ubv;Iqfrgd6xsA#Dt^Z~q0Zr}p|=FLO|>}#mfdNypmvxIJbn?t=L%SUgVo~4 z3W)+1``HcSgG(s_wCXW(JGs44IWvIWQ&l7dSbk&O;tq6R`kgr>=YVo0wl)Md1fp%_`YtqUIS%--!;i(LLM-)u5^<{;#B0*oJ8) zUQAy8HCKLKsC{gWBuGKTuLJsw_7=A8c>(;fCb-eRB5)ikie(#i1V}^z))rgV?#Pc; zTG$ORe~KZ!NPUl0&QtP_#j)Ec8rQ1lEXB_yi{FSgf@rpACGT7=l+`*=R_#97Wamje zA%;wb>WNVAGzCn60h6EFT?#Q*L8fY>>&hWhUb2%E;81ISAL1$nPI;l`DtN*Ro$PX$ zD}^r{>TUw><-F$iIUOh)>^=oM z;(<rj& zwgj4!$v8XlS3|z5=aE+Qvfz9TTS0u{Z?I^P zZJ0Gu2QlCzfAoBXywEejCUDJ+N)e5ZTpJyx3w9Y`fFI@u%`vRQmS-Ib zgfCgZiZ@hZ7;iBeLzwH6EG{+tN*=iw1uXn#9$?{fJh1RFh*)@2 zPNb+u6!D0n9Z{qsigNV!NrpGEe@B`^L=lK+`Vd7PqNzhX;n4VPb@}6zbZGnn-TpRW zYZ&5l%=xc>2lAoud*k)vj$VCDD2T@E@VB}i(KL+Lq6nxqQotiwK{U{9B#K7EifD%6 zP-~4wN>x&#OiCPy2LiSfO-vkV8UhL!ASVvx>Ht_Qn4oBIAG_HRy7~?Ne~{5Nk11xD z>bcsPN>-fM4Ny^lu4v#OzuGmLvS^^;81)5@YGz>1#K1P1w@A|#0c(*aEs~tYN?Dwk z3W~A_7>j_g2>6PKu1K>LL&=IHRgokrQZz-9q)1Z~D?xEIeo3+`L3O2wt`yCcBDqo& zS3N0_<|NXTM8HU-35g^hf3eaLr-PNMBpL$<$cTW8h^UA(6ET#CNYW5V5+X%GBngN# z{jib`z47Z2>AfyQ5f3TaAwfDMDTm7u4lmVv*o@rI{ItV+{ANw!;qU7a4{sHKsW+H- zgK0OIbb~23m4rB|lAcq7XG9AaiUC6~VCV%wghbeD4DMzU| z4=nN|7#6dYL|_zqyOD>Ckw01S#fqvmiWaJ5#;jyU(F%!%%*ggqG?$95E{+!-gNA91 zSZ$7>G-5&{q%&4Bf1}^vGm#k?DkDQ=WN3^Wi4jv6QwWRM$G9#1>!<|@k76{HFvDr}Jc`0@f5^R$em)KzwRMd1h2Rbb+L54!$~&Z8(1|mZ8tO?9 zNU4YFCXbEOog)Qzc>O}n@3$eEb58y9fG39(dRpmQ?zz)OQme`eH7!-cw2X1;u1Kvb zKn#D(m_`G7+TFDGBq&D%@%nABVykAp zCCVDuhLUb`*c(DWqM^PhWQ=KaszX{rQ$4Ifi_pOq=h>((=mKkswB?~%%<7J-sRkEU z4KbrPf80vQU*y8(X51NgKT1_N7d~tPZwUO9;BgsfheGX2=r9g76AHWN!lyTBJ*Bl1 z;ja-JPnr>3kRziRwVL0DX!*15zp6l*HzbuP@QWiHAJ#eC25R7MgEa6(^B+8IhI)FT z-ejP|nxGy34Xb10R@Em~i(ux{`pl>E`A)TpfAw0PbLk!@$dzW)WzDF`TCxK7Oq$}c zgKS>S>mY&DRH@p$+Pqc&I!%jsMNREQDctnW&OcZpg`EphiJw^yKXnk#tHYj8m^`D1 zc-3CdWt&#gagTIK<3BTB=g$jASc5<9FIF@$jE6aHlTnTwP>p4xVGbgsEkZscPfm;gG_DC-OvBLS0n{b4 z9CZEJv0g$+9P93Szqv(6KDn!WOl!rF#)T zD1oj;{+c0>G2Aj{a)+PSaV#;0Kt@hy4qX86vG`F;@3g2#Tdr~}s!*Fi>-r#|ty0KV zET)TXYCQ|fiij-02#bb+@i80Mf2EV+7_b{dZxfhrjt42aAYk2yN*Bc-+h=ExeR^e( zy~Hudet6h^dPR^unD$c$hHQI22)aETV%<)!oiCZmwZRQ%?`=C#M895~UK{-N{OGY_ z`t@nkYi}4^cKup&B&)*ESBKT!&=qVz^$*+aKwdL+qh7Bb=<@cBPenU=e^UKZnSM{A z-;?L}rTOo*a-i$%NPhWY*kS29>#159J+!CVp=SnxA)jf6g`r#MKy~y{6Oh+H>l~(N zwJBC@id386)I5}$8l!e9j60RgQTD@~Kgdz`;}Sq~r2RMt!5nEn-1X6f{UEMYjG70# zxz?gNl2vTzt2fI*HO}awf7h(T{%|0#8M?FFjR9HSzQOc0fKWsDG>A^a*ffYt8`a{0 zuHz$FQ-|Teq3gJ(YH{??o@%$A8H|Q}rWqE7ZqWlZ;6_bGUIVRpKxj3DRYOQMfK!7g zwLpxT8~RO?&vQe+X;Rg0=r>K9)(!loX*0Ni-!y4qamMZhJ~S9^gs=`QInC^4ygBCIU6MLw4srB1mf*V*r0j0 zzTLahHI%qFH}v;ff6DGT(p7dtf3GF&o+HVBY&@@?Vcmo zt#0V=wWQs1)VkFT{k@j5dw4J!@|i=At)W}=Kn=K2labdBf2j8jxf3+$`3M->rl-N& zJ>Yzs0}c63!_qN96NsqW-Vk-SCJ#f@1-_Js^R0=k`)yTh-MAv?UkoE;lsw@$_(@6) zjzXZMjNvE{N+}YK!=V*(H>#RpQ-bO>+;994D`-Z;HnkUS1-~#{m!bwVK!E@Z2ta@U z{0BgP0PF{5kdjP!`h20wmgpqx{`wPgv-n|rPUvEwhw68aB2HIEni;CTw0lPO2J7c?z zf8&RsvE636kQa`|b{lt9Dm2ZYMj*zCZB7=qC!qEuf6ShM*z<$f*v{j+_5<3`&coL5 zgWJf?ONPZTZ}G$3(9T1%;fKDFo#(~@kK*God_01W#qSB|y?E@N7ci%PF&w#m^G$>2 zL^qm*(1`|~1k(MEHVvm&OyAs?J})c}n$2*(@k8{Wdl(uQ_yL;@e`|pL z#ep9O!)*eHb{KIu+)NR$-7K4%G`=}o2dgP$bs-!uGoN8d7ftW&(lyJ7N@hPX#4SL1}&wCCS=h2`FJ ze<_N1kM|;?@}=*gp25CnKY7PqanI{>_tTf{6)&Qm!hO;H@}huB*i)6g59Tgc2HF3N(PEiL=9^9Z`vV|jii`1rcUk>UzMElBP!MjP(-xPg^ zDvNZF-`4`|XiIijvA+?0LM)rA>rBn4fAQ0;-Tjfnc?TP&@ z)bLQoZZFrtUS+Pw_w1(k?Bpug$-~Eqws#lkhrB*uCwJ^!=ycj%Zkl{xkbkhF%f`-e zPI-^_A}9A97xq1Ss@|MDE$rm6y%+saMVs#B&k^6D@C;?*lb`e`gmq zF|<@a*agmWJKZ>?${I3hfhP5;Da!7|7&wjLae`l^Y17aFL=9S^AMB*{sqh-2tBLKi zqdrG-QyVsZcGSGTQ@m#%xIiWP4XSHtZV>Nrav$AE>jh06P#sXJgMFCY*-`%WPSp=Q z<0ZYv1uhrJdf&_AN7Ju&lK-X{f2X@pr+i1)w>EJ6?C5pLPMS{@?}_>zqBA8>e)xc& zUYe#m$((*ISu_0KB<=HZahOgx`LlG_Wh3+SX9MpdA1&TRmq;q%^r;P5WU)4;PXXD; zpt_mIH%IP=!}q@KJu*K%Hsc=;{)K(&k^kxBv0PclpU*vY^t+?}r{mgSe?M#d3cg-P z)gg5SdZ38i&i@0Kku7vul%*!UR4nB&YDgK~um^(!r*RQg?Iy)@8k^*%g>xF4q`Aw` zw+SU=n-`|cu{Mdx;GI_W3?%7zmI@BTW1#f15}$uwp5XQA5?} z%!%M&@GiBZ{km3T>zrz>=_J3dRoOZh=omZFuWJKsosenCRJ~~Hq#J-|!mdF{_eZ@J z9VY#;Yf}0(?Any9F6F{=l;IU}i(i8wwoZGCUxgvI&gs^$4)}Gg%GNp68rF$^U8}Nn zF+EEy=Rm>Yf<#9u@EpJyKQ1NZe~C)N3Y=t+JYe{+x|xsAaQ~1#x6My7<24;CrI` zn!1b$NX{gVcxK3X0%I7qv93wxMU$g~wDEn=h1fe?A9MlsPTQ;B53-zV|h(;2VKgXf9nG5V4Khf|DL#Mu8mQXMB~$1HhagYUCe7T6377t`KQ-x{~etj1(y$w zUfn!gFZ090_J98Oq5JwD7s~bGnO=zJ<>565U3Q+~70T}#iFkUv9SDLtBF@uQ%f|<& zc#k?Df1Fg0Nm%EHqYKxrOWQOjM+=~A`Lpv<;nEluch)Tf92XR49Ck{`rh<|e4f{!#iF@hXYCJ; z>*diWMp8^uR1eAY5_n$c@+I-S#^RAa(~E&L-PDsDZD+39FdYe->%7Mv5ca9Zgsa;) z@6g48d<^D%d5|-jT{RG@?tIn_Vq~4{5y|#K$R)!N&CHG&gVRs!C;(n9?V4Cy7O(>ZKP~HU>(7D09-;d z=o93%!Ccu9cI`fv6ihEO=`yxdqE zy(8H+5Y%);9Hya3@?j;u%^Qp#-|h`Ye>r8|+|}1&;8;B8i-er@gxzyj-^;i4Bp+)a z)kZT7N#v5@iDuiYBq#+)G%cKNhPKLQ+!#bm?V6E6^;w&Q-4k$d1jOyqOokaG12f_? z4FQ+cw9pB;S~4HnWDt|JYX;`oXBh$>tAV}j?$7C&UfZic=T^=1GNj#$<9ZQOf5ljU z6EVFAo96)kxf6I^#1s?E^BRi(5jKy3G#$xlXDn@aAi;FLt1@)i{PpF)J*1YBZs_wd zfGo)QLX@TPdeaSkJ#w7;u%)3>PBNxRCkic z)~5mvj)1t05ho-L&R??*7hlt5f8GdYLb9kkf9*P$rFui!y$!X79j*h&YX>5tF~Sz< zcwTp3&ro)cVR{kk+iYB~NzjC5dEGc(r)Cdhcqv{miQ~O*W|xN7#k0BqDgkOjYxrF0 zY_3u86R=_g%+itPCtcljgxvrSKk)dbUgA}a!lXR%N??4^93=nmU0kOIm$V~#X>*lr#%r0=ya~$|3Z7sv=Qn9*j zUT02Lmlqr+ZLo+y8ZIt)f0ELHfh`SJ1*AR3gTI$JR9+aF$609@UI2c^QKkIwGG<{oab?o+!Q~w+>p=WSZZ#^BX1_5drXRBL30oWH{m%l zusM#;G7RhsEJy4`fAkEmw-dN5_b4^X>*ZO!j_>ayVpIgL>%JWj#p`0=HW`>*a7rF; z8SuO=I43xko!+TfU2s}YVs#BS7vP*AB9M;Egg@DCV6b?|MJ2W#;Jm=HsHWQxJkm;F zc=6mKDxNdlp5S>`RBUIuRlx(bFmQ{7m<`qoPC~#Rc+Nb>f9&E2yB&|moNR4ybF2WG zgD|-9W(NbBlNi5nWjNmcU^wm_!}sk3j^kiMgys9fV8nQH1kVdF@X52f25SZA@yfg| z7(XhC*=2{q!Xs5;I&1#^+p*{|nN>a!Io`6*_kKKi0jc z(FLUUu)QBkrI3~fiH-~#PE%X{5bnf#roCfYd0Gp!e?dAvoE^g2I^z#v%*zyDa3L)-)GXi#)gclX>V*a^) z^hTOVf24clcW~Ym6bfU9mSnK0z9zOHH8!P2bb*$u^?WPEOKhk&&`k%kVx}G%WAPI- z9tz?4gxW2yS>(x0oD`hoZ3WpaC#>)&|4&iFUE=~*dXDn9T9tY1v}+Y=4i#2@q|2iC z6nO>JNb`xp)(KxOd0S4lU9Em4NQNG4nAK{waMq8fX=7JHST{QMAma!HLb7Qm2Il_n& z=h|>MpAv#c*H$?0gKl&!NhzfNI=XALe^FRAEds8SycY$`4QHD2^qEjBTc-mY(N;K| zw8f>83n-p+ULV~!fjGfVN5h++Ye$d0VDeWaFigo%;~{V6sO92?Afnq}e`36> zTD6~Ld6fVrlV$hU3rH0Cgli&oihCq`e7ZhXqm#aXM`>LGWBdKUQ=d-rD?{Ha z0jW)+Gpvjt_Ws&uI=b(k20zf*e?DK_VG-`0IrGDmslV)EfxdXztbhZUtc3k-bW>%z zRv^51#ZP^pTg_q7?)&GWj>(-Qz&-v_31GBbS`8V&PNgOO_hViy!Mlp&@uV;}^thWy z_E2uFSM=j@;kanXNgB;Z`%8Y(uew0}i61as8s$rmNsDb0JT6k^%r1q?l@)Ck z2Kk~Ul+~G60`i{ly9R?gD z0d|*RTUi=P4eGK*b!ky+e>qgi4p*{6l?G2vz&_jJKI?GDbbQCOT#p4g?hK&((x+Bu zSUF536CineOh99RWg7OI8h6(!abQdFtXN{)o+rjJ^~Dh5_QBA#9bc6IzNcVYeel>; zR>QVx1-8|59NX%LhwZ0_?!i5n_VH6F+=^mljMZQ%&3KvsGJF~-e_FoffmhTu33_!> zzZrvQru3uZ6mn$xu>y88zQyAu5$LDJ`-3@PSQS|nfR-|rv7o_Rp!$dHhS&!*32Y=I z>r*0{BgvgP`VfVyZ0rOW1D{&1j4U36y|ORs+Y(QazTZWT$fwZ?xwe-0#*Jp9$U(mnXqxn@1Y zqxa{LlI_gcoG1joasz+L0SL@C z8Gdz1L;~O|e?%`jB0!PpN%B(H_>Y1dBp&_LHSmHt2#_nPiVb~rB27#JzCg1M`@?}I za?zb-EFtRKw`l%eOJKYj@F zI8siJVXhtUs-hd}&UOHR1u3C^Gmi8eX#gPEt42DGr%Nd?ET(hQV64l$bB1Hx23i;( zi;3)Tu&hB4X-#0(=hcB(n9SgZNqKMMU{%j5s0E?+!mJ;HJWO?ynHYJ;dWcfi_p7@1G1I>})wj7bL{w;V* zK~|8AJ!TOM!nr(BjU@uHR0co5%MgKN!Q`z*Fd6^CFmLh0zYJPP7ERu2M3Vs{%JdMh ze}Yb`HK_TIP&^+qB6=v8uL5jAW|1F8764n0vB!>usccoCE%=KR(AFs63_ynrtB6At zO+wIsQ-mHaaF9e`R3ltAOy}^!)I3PS9GOwfH9irzhIZbu5S1-Ypz}i6G?oeL?cbI{ z7!1|}m=-BZkNEY%H;dr6GH;#4SVZk%5U@B`|Tz028sDA8rls*dH03 zxK)A^w-^X_gkUU0hobEKb^8E8PbfQo-A0H5U^yILnQkla5GAG=yczz;CWC2@QSl0+;99X3?3A2xN*$Gue?nS zp76zxgU1^jJZFoWz70b=CAK^m7*@x#DA}J7n;<+34#jz<8zOLC5Mk+fb?0x9zHrGI=*IIDL_*5bl}QYeQ5% z_a3Tk8qIt5p@nOyH|^zG*oS`*@d#3`MCc3DQ3~NzqymRj3sMabV?*q3ZQwoB*r9cJ z#$$ncF@0o*zfWu*CwBv~e=m>zu#+2AQE}6sdlk7;j34hNC->`VS$8V?Cf?)QH@T0p zZR6-VnEu3t6^-gfdqoYnn9uQ^vX}dHtLSdzn98z+8`3+r#7J~aIx$c9kgrs-~)HPcS=M>XHG z4_u&9gGtjq`-sj%3@E8+8vC}B{DU^I@9Y%*&W@OWc5<82{#&%ZaGro)PpQQ81F||) zilO%mu2=n&TSAaZL(S55b+FE_x*Qb1MQT_=CRyZamzRn;h(SP@1SmSm(29 z5o|jCHjtkJtH!W(Gq_4;%K>#Oa_jY4K=@G>Rgz`uwq;wiA>U*m32!q2stg=Y-MRVKdOtre?5F3vZ%k zZCo*it(n19oP_|j=sIlh?}f!fZ^JwoUUcm27DS3fvl1i0)Y zK&@M@eovfGdu&TAq4@67YEG@=ozlAMGV0aYvyF`;t@jX}_e8<`q$bI%0j~b+jP0AT zZL_lwe{zqEHo@-2KQUH>J*#kkNUf(}Ju=T~E^SuRlK`S8j|8rOc8I1SGjgL8+$=t1 zm(RuR^7!uhw#`lj$vraKcqmWWIWS8I7?ehBeVQrm4Npkr;h3t^Bf3$UB7q)bbhUjbs$b*`ev8AH%R@xAU z{E}@jhoz&pzyVdoBBl!XHF&K>F?fb=0(Vp`+WOqm7aUiC9e#@oVDmUst~)TMQ!{HI zN{9i|SlqPCM>5+6E&@xl4MZI|bO*#QF_0Y=8U*X0z(i(hwrhd05%XbWVy{b ze^5CN6@aOnTM?p}Q#|0-X!l|mV9ot76vP6-V*u=3dzAYO@7iO0j|;~4Tpzf@#n|ER z+Tn@{12Qobp=-7@IJvpQ#n|C@37GAD5FKfciA4@yVn9NWWsewJsZguhjtfx_JP6=7)#ve}8{^ z=>F$_A6zJZk7r>a4wi?xA^0$e4Rg-yBe+*AXKy5u>v66KDgTSA6@mm_wY7YBk@u*> zSToGyToGi0%fr!yYvB;*iXw6h|9Ta4Be_-=VKt9CMYF6fnp#to;27>;E>(FjlLwgd z*Bdvj%JYTO?k!ixY6-`QA%m#+~;3w$l%$zrS7` z6#HC;%STQB{;FeWCY`St+%o2$-HSBnNS7zmf4`Q>tCVvkj=GV#NUnKpaqz1AXf9G~ zV-D4z<;Dd$Nv!EPduKJB9c5c~*CEjP_GNb+XA?vqiTFC)WYczl=h;oKe}(e(rlPG= z#-77a$ea3^qwUXC&#NP$ke&C~1HyImEU{#J7dB>Kb%49KyEP^vWK6FP#N)e$;=@7K z$#qijC`c?K!!Wc#GiZkC5WSh3mTHFUC}_!MzjiP)ohPone`*fQpjD?AIaDJ6EFMfd zmZ|h{jUM^tYDI3{+_!Iof0pxY+-=lsJ(#4==jn&ClfIq184lmp4a^5+?^>d(8ivVY z2K}Ef>WIm5*G%;d@|ee>R zIwGFX&{X-bA>S6>M?7H<@570-iSFu~F>pJ;>2tsyQaIsj<5E_Te_|33wDL$BNC9vS z&mz2dZ8VD6;0fsJxzVI5Lt4MVAR{c`=H|t<)QkW@js$-3(muYH%=Pi})uBE^795`T}>EQVUHt^!ymi9YO2c5v$O}xcHh5fBq3fh-Aih{yKIrBlU)~ zavN$rJ6r>h*RCa{tlTs-a`o*9@cL!SnM9Hf(*fO&yoKr7_Ug|ivGpcF6Ufu+&OE*A z#1+QUQ@msn*L&aWJPoIbXEXss31oKgypCb$Glh{i3Wfp(lYqN9@*JD1JB~0n;Nb@z zuh1(v6Zfzwe~+F!@(d=-De$k2!>O2KK`NK<{0#^#Fpw{JT_*`r@2Gi(Xc3aH#88fS zIs2=SJ-Un`jU-@ktf@}IzC&wh2f$6j zxB$%5P%-juo`J|L?L=lNi38+#8azv56dG>xvhloNe=%vBorIhmE;D$d*@1yu4%Y{y zoyL*{y@C@l4lDEcIF^}r?=n0?fd0)S+}twud&4UJ@O%vsB49+m;Ba*);F`FmoghUz zz|eyv3m#21d@}lp40&Whj2>eNfgWYPQm5oAbqrsrLwqHkt)%2CbxN*M$Ma>P`5A*C zK#vJ=e;hx9$A=N@j0o1AlE2i6{3Q+{!tt#;o^O2%owu18d(&V#!(S5cHAkLHGc?%< zU4!NdKh(~!n3y?Z7ZNHO{mcv2X=4bn(Qpd4L*|}< zf9pQ*Oz@sz?QwML!Nvv?HSD?O)4?Jk&~d!s!N4M6OlXwg;;{X1nZWPRf-|^F%+?!h zis0b$a3(=>K#Sv;_jN%4TYI!+LPtIjV|%=PLPvIQtnJm=dE24h_ekp-B!b_5JJyva z^TsC<@>}-#6W#Rf^aUU}oh%P|CUW?Ke}*a^PZzn!Rton_UNZU$uJqIUf@%i|)()R( zr^58|E5`RrwPTf^dQ;z)%0vBT`q|{k3)CeDb!hIBh5EUj@YQ|uxVu69WML3U_Xy<~ z|5k2*j_78p?*-q&cTvw=SYf=B}|pi=++Z!b;y_LpqtG)*DV*%UJmq~*o+9O$& zp3{@+Nd)l~`vY$?{f1-3LA{f6G?l3qlnsaw>gSN@9$m#(F5`m1u7b+h!Oy=`jH9b= zhniv!TW`lzpp$a;82in*% zf^_8bc49lX+%UOX^c_1-pbo#~Bnn%ufr}zdi_x&A-hh0R*n9c z7FdE&hn|?9!P#YO)ixEcmXA^A<{&P1X*`hJD80sT7%+*>wx?o4N$BPrN-0#B-%^eR5NH;yJ*%(Rz8Gf0y%9LowU&?duZkV7t^v z$sejg$C*hnKXcJVym+=#ur&&&O}K|X)(`7QgZDYKW5 z#%%AhG24;JTANj;KppLa7#sp6q>bX3?1K?b*>O>E%#>DUi|m6KLoJUuVH#y01!39q z%#=2Wg0z=ee=xfs;*X0$(m0-0<9K$@E*Bvyo`1w3X#}{83u0XfkR!+AZ*~HjDhKRr z#{-piJXcHF(piLg2YEU+h#F}*m}ZObXfb|M%eY}th2Yf!`o^j;nvSD*b{rBkoWvT# z80}Co7+|G(1(<4=gPUtPn5^T=779D=IG}C_AX$K#F|c2!z`Cu? zq!TEUdp-|`aeene;6H|SD9{)MqGcGcb;tYCEe|%`1)*1WJbIP!vJ|YkZ#|5?WmFwO zuVUao-c{Y2FVl#$P-;mh~W@k zTY41amJ@J$HM;1~Q~dqFj?5# z3aTXOTdXRI0Ep$%*FREby6+ydu%~mRK_W674C9RfdFm`1Ps3OQbH))1tm4y5YL^aB z_T!_^Tv_az(X31Ylgj6OHqnHtHqnBp2rNap{Bm1zBl0NWb`xMkd{oThQo0{}#l<;1 za(ds%!R6yoqMZF@<&WRl=S9BQg6&Y?T$+Q37WQLq0TS{ycPEJ0c?R;HO}vL_O~0(Tiytnglp;Un2qO*9*Wb7k60=Roh6j|e4k!a*`z?%N(2Y`hQG7W` zC8G310r%`kh2l$8P)u4#pR&bga7!@u2uL z>{(mhqALaErJbkegxxTI~@Gu`0dC%B0rD&k6bOZFyx(x8=?gVw}-BH+xIgxH47% zm?^KcJvctsBS))QKwIK9hwf0ExbxZAvY3QU{r-tgEO1{)R>2*BqzC#ev*YnKVOMsCCC#UuWV(Nn?l;qo#aEe< zJFYpJN)H!Ba>t1ZII6H*%qDPxz;|mC@K^0$U{QgcHml!rJ&VDpZNANTVES`A=KfkG zas9OjWk>43WE+TJk6Y4L{z0S|nuazi8)vuJ6a!lh(c#CsSnTpxB3!a6xH0q#_!Rr% z3Y;2;j`dM<;^}8DL#|z#meGNjqJ$*bAz5Qv)@Z@Xr<&Kso%s%mivJrCzrOF;&A zTezrIEc3ISr=`^;uZUZ0DVE;8yk50uy^v?VM2_`MS{co>WomBHC_ zjs29XrYrq=N@+{R*Fc>575CfH5X6Sa?stf1NCu2x$&@`WwCvp(EtP4Q0KqQBPKh#( zYcJtbEzLjTuVoNltQh7N=OnhDA%(d0h79FDIvzr_|BOy|y;1NE1BR3zBS$fr42_ zdTNQbY9lTKKV9~bixbE4l&i*+nNvG0xsUgP8py%-665Aq>!gKg#;}Tx61zyuAJ4~? zqRv?J@6NT=JPlv2QT`&u03_f=#qUy+Pe~b9^xUMZHw>VPt=mXadfD1x-2nB80+|^c zBX81F+Xs}IFhg!jv~<63AKk@o&{uaR8+JXCju zQxH`@SqhX?9r=tDQw3r3+u>*W-_Nrk)^8j?y@p8eQy9eILzX_{KcQYCI(fl*Xcmb^ zj1VWyFumNp(Y+qg81FA{k8`~croFg^KvRl+eb(NNp8rsSGIVg)9l-8nq7w8Nvj|wY zi&{H!en;(EA4;2Dt`cp8f|^>2@r4M-?%2sDUoPJL4W*=woK_oKI<^FNPfw*XV42>| zK)+K)%+nP!QzV0QfnQevQ0~8(Ji7~=mzwxvmc$YNveS$oYaYCUSQ!SN^0?FH(kU=Q z9@yRNi4*-4 z$!tr8Y69n~u{jDYtYl6vLZ8yOPmOn46D`d{Pl8Q;+kl=8VApA5_3rihc%WxPO855* z(&KZ)#$!``&vItlo*f^Y9cR1i?S=`~I^+H3Umop}Ondv_q<^ogk*zp+WrL3lnu-A^ zcGG2+Ojkc)X=3N9oe1_v`iea@YD!feF_lH+p{O`_pZbIXwHZ-kspRQij}b|#qw42koFEy8Pb0&*%J=2EN8*v7iVn3g4ZQxoGp3P@U?kcNez1GcWD7b=hT`}nW^Yh6N z^|Qa*oSfa9T2=Eg*ZoOrqJBLhzM$yZPu&J`n<(e0D0kU%tX1BDhcoguSV;(*r~U&= z^m|*0b_tp_J*srra;xr?OMxNkva}R#pr7VauOU9eCDF+J zxiBSa=rb1Qn;Zv80y3N>vBVt#=20kDV88J{UdW~^R{>&L-sryNIZHG1sYNFw+SN*d z;@rC#5J&Gi zy4FADHilVX3iB(9;sH0f&e?<3hqTJ%S`p8*tk?@%wA7aS>8B@^g9$ zxOXEx}fkHp~30?D#BLxkiX{D-79U&Db9csfgcM8zvLhXsJn9 zO%XSG42?7yeYLb=n$n^smepCw(vj8vUPAC!vcLyhAXR6E&hunFx)T@&M!wfGWKY6!E_U41$a~5{i`sC~bi-$4xnSO$xD<-St)d&f>;^0F^ zzJR+RE&dFYn;zjShan6A4wu{mk8e zuZpVTsKWEep}>7pTZO-Z+J*;#?E-_?sV4;Yuo1CVIV&5@Kbru(Psitc( zvTVw<9GC7KrV(i;(8nQ+w7^`q{z}3jOjMmcpUGybc7ekjEKmhd_X#~+dH|P_4%rf(h)18T*W>X zp)i*^pINb6iVdrpL7dpDYzy6%&d#f>;dv8z#nSt;7#y41scaHZY0q?k?g~Y_1UOBjBu-c;O|}~>@|((m?3O4E_O%w zJp7-F__^H4q+OR&QW8Qb7+bk{7u2(X6%DjFfjgh$qTcTDAd&odA-y%x7=~$jQfuWv zkl``PYQj90>j!EH~v1mTIeu{;kp<~f9gFBa_c<#SVlj36Ec5z*)`xl0Anegu6xiBn) zchj-!vi0NQvt`}&22wrZJz3~T6iRU)7+FnT_^Uk_Y?=a~7=_l5>hby9p+jNtSm8VH zB!Ml)V-ZFw3F4=p?xN>AFSt(O_42TAEqac7M~-1e%Qhkm@>MU=&o-C`q?FkKQCyJt zNX8#t@EszngIYxbf~UD#F-HBiG5_j|A3ts{Gx}z{5#+ABcG2TECQ_6sh(kVgNz9-! zQPg-|Ua|KC`MpOL!R2E^00_e7u^IScL;!~0`N6ksm%{d{V=BAmb*@i&k^y^$xZZhi zIO;Kq*RTp<*;FGy!0g!1KdkzpV-zoBi*%~9tBZh(OzQC1zmz+%n*UNdiQusn)8RY! zTPkg}zo&wOx_5GYT}Ca^9J#XclU~~%D3}2oBUSoeU@ZP6zFX%RKBK(*33UKH95DWE zj06g$6cU9B^7`S#Us8+cL#LeM=e8r*yrZzINCVcO=^w=LhDi?~R-$gC-& zZO=H10ekcRIZYd=SEc}t3F+%xCsblNV=VA6IiQg6orbJX*zNYzav}f^nzAbjR7m-) zNGPWdxg}T+awgfvm=Pf1mGodC8R`iQ1tH(SwO?$Vckd#~Po!g}2wbA5xm|w@dbwKOGEe3X(`=P=ZL_y(~++Umd6aZT&`3ajTE5uQ}1G^nd|R-c)<`0 z)Ov6sSwGw#JR=;@B&x5Q*&o;*3G|m_+F;-;hjb|t#XIDmg%9lI zPRQMFs4m67UrwS`d~{wqVo`UMD{bkTyyD0F*v}o|y1)MZqkT`~<&Ihek^YT^T57;T zd6MI{(s-7nvkT z!g-u$+3eF@G2a9f*(s)EJ0TIn-6iAYjP#I_`md)Q)?8D5WRTYHRo5kK!N0dThrW!x z8ebQs|Fu^fp1w*>^x?11}XXzQEP?(mjsD29)w5j@PSIqo-> zZPP;d^mt&bwzG>jR4cwb=z>ANnMilpV(2RY#qf>ughajMSxbmhHb@~S4 zu6ig|YHvGK!n-K`{52n5nB~oV+>rB6rk7aDK6Qi@gZp<`ktEm-9JbSTEp-c~V-0jO zj-7Q74fxIobW}8OIx}R__gt82k_z9v*V3H)0Cc!tJ8{YftgJ4Y7l`1DCOYK1)Llt^ zsfWIOwBTXBDpTuif~XN5m8lWjkA3ZVC45EFDSNi5x%v!2Yq|&Zc;luZ9=D>tzi%Te z2>ta*4}pLt?XDr~FzU1!zYSYSaCY*r_p;K~Ugx{!B*P<`zQA1Y6pd_x#AUk%F+EKl zkO{kws_-M?WO{Oh5P>GAFe2HuX}FGd`Cu8t8zu=U=>bv}ve3{;pHX~Q`laH5RXQaX zF4MPK!7=aZ(DWX*d5<%bBK*!g%%7OnBdO-LDWk|wK4QUWkF9Byu7~d4$Sh2z_LhS` z~;>5qUKEnUq5ah58`$XTKQ+zpT-TyQ&uT zD_pdkf6!L^v%)HNmPQW-7GZpqwVFiNz|>z4xkIMwY?b#l@mlOh?)UV%Oi3Xl07oN+ z!RN(}_r($8RyFCVC_%T|mO|aoODi<~bjx;bHW?2%zVEg$XSo>CytTymlu*t#J-q&r ze{XBNUVKPCYB48FBR{ra{8NSqHb=m@jg=?|WjDwy#&7Z{coHU(DU&gijE?;} zT`@BaCmL9>Jw3rvRZ^l2tE+Tw#gW3@(!7|xFoYo1RxH?A&s0iPO#7!4xVCR7Yu^u+ ztsN^v_n=*{l?7c&!Dkk6?~JunYVWP-<^@xweBW5KWwtBtBV4-H@Ah^(J};-=^k}pv z3e;-4Nh3>MnE*$WT7-XoMDj?XvyWkrhpseQ;x=jVfUWkIHqzHm~T5E6C^-~@ly_5NX=^C->vcL{$ z5ZPS1Qu)6S0~p*1R>u@JUkjnQx6MnVj*`o0*}diL%<=c)HV3Oy5AB=$Rec z37tF!XcSIcZDwsE1!ise_7^@?+n%oQAG574z1#&XzX;Z_eSp9L7Y|6qrn?(SG0D+~ zzrbeKD9e@!EKI04zm=2AiTE7EC%E z3+SdCyM6ap^?BBoOU@uEmVdC(QSXnF?aGcH{Jyw?NLAWZ&8^!HL1Uq5;$D?k@4BS~ z)@g*D*BqTjyldOQ#D`MTm9|(DSiSm`2?yGF$Lfxcy7r&L=gy|98>VH{{pqPqYoZ|C z>JSF}kXAxnMZ%ELAy>-RvHE9wGzfQqIaw&8vZkbfRfP9gg6XKQ36>g96 zBOD~YfjO==^d@K)=68fjk6J`4qvE_$lt1N>uPag#21{`Pl#O+~Cy#v!vBd@9t|H3l z16wnORBE=1o#}lFTSx2DQDYPnd%=@p`-)`oINQInWq6*w?oDQ^&)sMAj@&M~VbPT& zf1j{Ok*j8d(@ek)#5TQ1V8%&}D!Fb3U|L;qYW~BdPnAlGVO;&YK2M?~Ny;jYn~e=1-|+zww+bwV~LL zzMv4LIp4#*lV5f?)^<_jJX^f{^K~<8F0HSFmeD4y51!G0a_cHN!(q0=Z@M&x1BpRY zq4UNH)=l?c`uFV4Z>n<^hYP0aOCQJOThAHb7ms+fKs95n#$?_P)*6lAi>XhY3YLid zrN+mojCV@Py9BOe*QdZx3moZZGoxgAD_`lNn4}*OwY1P=?>;}7N7^%+B_kZ4Tw3)h z8yHt88kC2LKjj{JnKmn24-_Za>OK5Z`d5B?ZoZ^#ligu3jZse#)3MFj_K3z+`NY^iv_4qxv(51TGM zDigRX15m})2Q$osz-(7;G`Gu}HRUnrhZcR)O)Qf232uw$#Gj3mp6E0PZ(Vr;He@-M zP>EJh880s6xBlK|-}zEB^emNgw%Q+hE@n;Z0f*t>DA9^|bx$6pBw7mHMwAUJE_zD4 zh5IrVJIv8WlxznOV)KDXG_3Af)14-%(|YyU2oua^;_js<>P9O>q+Ud7u# zS@|V#c*YWuqS_~&wV7hkkfNMRLMy96vlq4TSDKlZ&^w;Ywt97vS@BY7tX#AHWwDOC z0{%*7JFgsD>q#IzuPu<)qqo|$&SGv@9<2#Oy)&5Y_Kn6>IfwQ;-~D;qPo@3ezO5NY z9-}o@5i^|E=v!eT{APcQ6VWNy;=<(l%~?G@?~5!bGFA}d!AZ$Z>i^=4gW$Bn$Vfn$PK`yH$vJ1OvB=( z>0e{UrP7l3_4e~p685;Z=G2E3NZg?4=r+am2b!$T_==J{W}7CjQr zw#Nt(`L0N1$CySY_V znX7wV^Pgm6shu1{xt+y1%061@{M8+kw&mrSQS~#XD~ESqKD{a`0*Td1Pio{u&Gnh8 z)^mvE{N$Nx@$h%-)c}DAKaIp-y!FMBLPU(>J%l!M@U!e0gZQzVibzL6?xZthxvXH| zU4k!+G$u^yg3-(Vh&Rb8;1eR8SK)@`c%gg@`*i5C5Fj<$*JOXIq9_NOb^Lid@vS+c z-ZzPzb)B;LpO+@z#^oyO9(UQ9Zd2|f^QNZj>R-d8j37UYrg!y+0qNTk@V<4}^?vhI zolI#_1mFk=g|Xb49DB>TwKEU?aunjwxWZbzb;Y;!)Yn6i0WN@QSOy3sXQZr&$lj0U z1UC64Jm&lq`kfD5@w8&=sPAwS7#D(L^F$G?X5kMP4jF6{~EexrDZ%LPq!|WTFGTDrT=dL|G)lc zw{=`em0a`cR_6vdm1?X~`7hv-n^-2*e)?2f^s$&|rCa)6;DnBL!AIx0ny764K%-m$ z@cEf@zu~6>Ofcx!^H@LZG_kkkN$$%dpMWR*`96gjOhOE zW~`7|ZUY~rqf5YRtIEbN)x0)6%_XAj2n!*wf2)wGKo|oJMZ?g-PQ)JvW=N0ZV*{P^ z9-?BqU4_|caNYg!HSV6T)1wj{jNp+ShICN;f0#FH-h^lhqf$oyk=>F2ykQXJ6pFC=V2dr{GAOt)R8t~UxFRfl8K&5zzle~R zURqB00BGFHt$7}jUjgqF(~%7`$E!(53Wph==roUe2=O1v@Nn+to@6SX>%1&XyHe26 zp-Bb?KM(?Zz>zy5At{DX>LyU;z1W#@gM+?r{^l~!%=i48*{^L3C(~`bm7VLq-LCj; zK=dU`O?qD0w6_wybO+qBF89e5gH0~oyz=%j0~lHCUQn_#4@Ue5sZQ%9Y-^wJpj$Zj zUQRVL5{AvMo8|HY|5Gicy>Di>j=RO>c7En&Q(PWWHlD}*qr*!A5U!Mt`_{-$2Kb2V170uD(?4Bi^mhe5=5? zsRZw8|4if~9q-_pnA0#W4{g?SyO-H3g)O5++Yi?VXYkW?3MPG@_&Nz$jpIH3lM7Hm z5THXv?`;%?=&$D4V91;d?ou~!7;Tf|ubUIZu+KoPo0HsaCvLv#Q$pJerKP9Hen~nxQM1D?Rvy&`bc|LF=X#GNLu8z{ zzFVQ!Iog>+yYGd%i3ak{pV%0skdpK>+q-&-TIBcF3}%b18dV022eYDxU<00OFGEUE z%!WUwvX~8hC!R2#!`lStv@k5&Lg|#^W%rJo+0Buq0m=5f#Y6J1=D$=3I~c-r8mLq= zn(E766=B(~(<|;Hd^L+Izy9(UnW7`XTQ=b`O%p?x$KPpKWt8GJ#bFqp7d_74XH*rR zT|QA6%1WZjFEoedz0s^u=m5&a8Hq6Kd4^Ffrs{dVBi4shAv|94z*SD+t5FJ{1dWLR z9|&qtX_qZXJX1&`{e_R+GD-ic29-zg%17Ih9icS>t!<@tBbJ?Xg#+Yn>5iXtbBymW ztKPeAswRmOx_2yqS+g3cLA7wW&Z$G)RKplGgSOSq>DK;`Gr|aTri%$h*=ie(H3f?G zoC3pmL-EOo1;7lto9}XK#INt>6c}7L+Kvb^Fy2QuO3+`&VQjLPZmaj{H#w$5Seva+ z8DNB)H(BWa`Z73xQ38cd%P1J%l0eN7D=Y?IuY+u`nbL8Z{)&N}gTsQfva?^b?esd z(tvfbf37h)f-Zzx+ICnZJ%~g^>iNy~y*0ynZX8%9no9LV1x90Co3*uX#Z|xF9h#mv zMQMP)F(D0f2_WKcIG-)tzyCdIJP1*mndL6{@UKZ9>?j! z?o{o3jQ-5AbmA6aHeSB+Rjqv>nT>V-CQg58ryBHliuc~i0j*W5OA%W$lG^$7XCBkB zG~_pfQ#}853{at=Ns7pRt?+-w7gEF#tB?QI)X&wJu4AO6%Kbxd+)Mr|0RPkM=O3an z>I}~}-!1ZgMj!d*-|JK9_Y~sm%iXxV8)qB8z97aP6o4Mo<)PbXk6=vwJOQn>kPKh* z#@r|TPG33$qP0pz%R;5ltBCSK=`2*>e-Is?JVX)F0Y-=>mDo4t5>BKSU7{b_r5Bv# zmCHUFS_wReODJE|gyfHXpHa%rXKE;u^>r(sHx@3ceBJKa%C~gi&QILbyW0NeQ1g)+ zr8TVT|K0uHW?;OXtC;>iaaC__Yu#A*S7qns(pJ8vy8)d4ud%$fOwX$J|BvneHvPx+ zhyH(?lTKxI{2%&dr*F5@{7^hw3S?mk2H~Lop{eA|OE`wdCr$m0%hQh>ovXRV_ty_& zo$DDW87q7KrPsOEsum)q{$ZTL*5jUEQmC5wsZmc*GE&;3!0&-xYV}&e)d?3G$;4uE zycPO?!*>2ALqBwfoa*>)@b3_vUYZn?z^R2@GR{#G(85zQobdR|;*~QqPWDtNi+Q*g zMyOBs5=aHRGnu z2{1-RLr}MT88;E}1Sl%dAk;jL2|IgNIbQE8lkogre(vY9k?0kjDpxur)`$w*tmgw~ zIl-r5Wux-Ynff`-8J#kwzm)0E7$ccB zM$UHjm9?f2#(Y1Io&_B7uc`G^x0+&1@%~AFl_0pbN@NPQdveJh*>oT_1M?))GMP>%H*SG-5z1Dn;wTf5y|p+xDm}JBqU%RZ=X8eK3sE+ZH7$~g!2K^ zt*BOk*=yzNsi2^I&IfEDYTZdjJIA@@=pG-?@|mok$*mPR5Pg#|&!21fBA&9JIw@Y9 zaoUwMb-*%!-5VpX4}YvopqxRBa7S+>adbV1GHN4GK7{NS%y`wm#|1km}M|;Y_{gg%dup3bU zK@|O}zjc=PRHwj#D&er8)R!aH9)^go?-v!a(M9!+pkQjXJxw!EbChG)CZWJ88S0r0 zqgZyifuP8$ckhdq*F@PJ7Cmz;bGa97Dg9xt)r)u4uy1<<@(2UTyx)*@wTvgaDhFCi zRM*AN4}EWoGfV{DUU9C+P1NI(SCSpDJi2#d?9_+Fu8%TDr0uRPFNUhRO&@CX9MGJV z&+8Q9*=iT#VkhXsYH6{PBIOaIHIpJAKsLzMgWF*Q4hD2yN`0RPo=SWkIGQzWJ#5%f zbJ!3SROQd6-D6z|EaQV#vTP1DKfFC2<9f2ZN1B);fT_I6KX6|we%->awc@e-A~w`X z=B%MpvWu;1150h-Tb-=sd%m`EzfQ@NDbHV-K&Y<@W8*cHuP7(qm%NP|N6=%pJy;Bs z*Wd0}<1}0GlV;}xldp&!^FrA-;I~K{Q^BAp1fq?nmAZm4ma|IhZonBC9HSuj*!@=gnBrzN<9O+$hob^|N^@-R4v1g`8VlxK;39RmEo@ z(G|q7^BY121w~_{VF*m-)%<|n6xMng9YN5C^3rbz>>HXbUUz_o5^qp5h?zl4qVy790MW zyVT6KCK~*}@g;a>lL3Qi%_5LCEyD;eTKcb}HjN?Gi z&6laG0nVKMtJ+%+3cWDnDtF^x(Y`ukty_eZCOm~C@yz^@Qm(pTzP?irwp3@me!#9MdC=tr#CMFb1qoDl$ z>?GG4ae$oai>xg7fCuxL4Gjdg3pOX1onQCJUS{BxpX74Ei-MJK8IRD>;tyePjizAR zU$A$%^K7O(RwH_QcBY-oiNb~0bav(Gc@K-to3UT=T83kxA-9ZGdj ze5a!~JB5!K)|C)VL|>aL3Q|<{wQF3!;Nt8Aoe4L1Z}5J>@XH+*tDD)f%gZf~*Fh>T zs02d)#p`)}#AXP75ODcy*p9XIJQPf3uHR?idQW+SDC=c8Em9&R$MWj<&fzZ{&i71` zBogC2&e59hibSYx)~W3;WP{ZXT9h{r(&1XJ59M;~L6u}XxXYOVr$e!ABnH~sn`B1} zo?Rz;iP(peaJVShdgfDjAoH|&c3$&ejTV=3L2TwbjTV1_s30cuyw8Ej&;O{!y!tbP zf~nL3757Y5>`I4$jFGCJ@veN%prQs6+2tpNiPYC{HcKc`cJnCp0_&;NBn0d}f?i0~ zYA|D_7fosy_;b>7FRZMn3|4bL!?%=etm$Z2d~hWK0$o|j!-dV;;v3d>ti_Z4>9*gJnWWhffdgd~XYT%(FmqbXwp_xFB|9!Vy2)Qs ziWVfZezY<7tNvV^sV_RBEWb}oe@ILbGxn03+{;H-BDK%m?nIw7RxBHy$}4WHIE{hM zlZQJgMY;D-IgI9n>&}Z5rjL$$B&e(uwnY*aFnD*OikX6c9?KQ(9N`-w9})z{V`AQR ze@X2TK~PV6gJ_^KUFY;zQ=>Hxn!e4YM`W+njmFnsaqQnFi*Ot%mpbipeRJ^@D)2BJ zOLfi6q)wGS@lqaM=YUmrYD=e-d&;2decqjo+qwdi#;ejH)9~;eI4#mB(b>Ev`73A! z{N3w>N4~@Z8`t?c$`VwUYSYC@oxmSexG#!i=LUjU$#UZHAdd!|?m7{qcHBV}Buf@zagQ5gc+}amWxIa$e9t zA*!$#mqLP|j5GUq@&`brW7+E7#}_dRI44aj_WEDNEu?7X9&nZp^7X}P>fd$A!rQ+( z!)z4)lTXj?)H-0`DHCd$4rbb?kO9b5OR>m=khKRXAPI0@EjHgfAy&4sRqh`?63|<_ z-|PIv*9%6|evTRqnKq@&6o7w*bx~1kIy)nZnaX1~bojnA-{8NYY=Nm+9u*(~fd7U0 zhqg**mf`z2F!VA0fuF2O7b7G@=N|B>ICI#wv>wcAH6wn-`@W@QiAh%~%+&PZRw^va z7plSy(}iX?v1 z{nZyx>Z>W;N7;zri*bvvmVzkIi2>*{D{rj7PoiBmFU-zQ>{rWd3)~!%KlOG%Ob#f? z{)b1hqKvAGPLt6a^{ri}aygK#Cep!IuF;Z`I@9B@)``3fEp29UZ z3C8GU7vCk9+|ve%mok7_8``#SoQ}hgJFztI%Wf4c93O*f2*!c|Tj`@n#$S~&+T|s3 z4c%W0|2a~upo?aYEIekmS|Q4%YyJx;)AzWxk}rcML@H=&TECXl)&T#5{~M%r{eAMa z{A*{GT)wHP8~hh2((?buPFrKg%^Vf8kI`l$j9E2VR`6;q*Tu41nAmc{t^^2fu|$wC zX<*29v4;pd{4L*Hs*|qO*kRQ7(hmjVYT93C={gqmTy@PiNz>wvJ=-QneHV19(Q02? zTk$0JFt@8gm!}ygrx+T+9ThBssJkO&~93%R#wy90Lf3NfZ$HeDT z49z64;`=5YF!k8tE&VPT=MX^mV>EDUy??VW{-4`v>!zquxU_Ls5z8O z;9XfLtQ}|i+Ayy4wJI#rN%pl}g&Q@wIf8B?!|AH?{_AgO7Ou_==CKCEGSgM3U-&Mm zYRQ)WDK5NI?<=iRn=(oybc+^Dwf-{ztGu7zy40HGys9Ll|I^j}harM)PHTb1mf0hw zRi=Eb;Xj32qs$8j0>uPvXgeRD03Sv5EJ5pG$OjLH{>CiY!eDy24BgaT;mXnWVAp9K zfR}DgeIQ9@H^?Zh<4aA@^%{37SXj!mN%M`NzBfsuzkC}M%xE4t-L-2zm;B{Z6SCA% z0v08&E4wT1DG8w89d@bM#4SWhF0gr63itHEJt`-r@0y_>SNG8@t90)WdLD~&dC^l- zCSCvXk+4XO_v-yt1B>n0s`tYm{d}|*2y}=j>>ByywA)3vq;9|uMZ|;j1I(Y)dtc$# z*VCtRxh-wFeg!4WlO zo4rAh=JV8b+_wJM7qFiuU?HqbA!y@0`m}wx9MeKSumacUPa&};^}iLF5-b=XSR#ca zqtBq>*35|+bFtap9Xj#WIRAmt1srb^dxBiR;STGxFJ&>jV+*^6lKkAkR;clZt6I)~ z?a~h2Av5lAYF>(Act@*xPaGEdK^ye1voeH#XL7!!`ja$UMQBh{LQz>p0O(akCNUTy zgF#9!<(LuZl68L(zP=Gr_$vKK0SU|dU!y-$0Ch1* zm$%yiOf|Tk9vM+t?(N;$CGHTC=R4#&DX@@Sh#{(rI`Xf-UH<7*Pi;S8_Mhp<;_Pj= zU@|PEt>NdsKeru_^5c03P}&}*|IR%NxXo|vS3mi*MCQrh#U&bhRxK)yyHtBXWADLT1B;N)&iysxw>pQKr%%cB?B9-H)Re* zg+xTej6R+Uf@CO$Mex{yW!)pQuwgB!DffIYK z?cBgSal)izM0sK=;c1{EX#(Lr{Q5?fb>yOaNMlFs#;N+nc7ff?{Y>mVZ;H0KsD_$W z5*n|oDy6%pz=f;;Kz^&6Zn1G7$>J6lMERO?A*KC&pGOQ)U?LB({;0mB^lxwH)U1;ts33eo8+gl60l;cngtRO+M!*x4OJ!bxC<~_rlztrtPqM_ta`(Kuz9Xk1*ayR~W~_ub7gI zFuBI_q;GQOrnu$Kya}MSNsRc6znNMF^nA8_suItu#VVcm*P)*xk~H{rm)mbUv{f-isGz}{HMBsn6HAW5Z_#5o9QW$XNV(G;=@G9m@s zH|>DF40~`2CC7x>=VtftlS+s zo-}ieSPWCot<^DF|F76Wesn^iLOFusF@yjtrpYoB%a=&1K5<&)nj)q?NP@AD9XQ+# z|FIbFknKbRe8zk9PW)4V;Xure4nMF>%H~r zGL$fmpVQTxge{<*j7Toz&b-J2Fsz?9aZx-1ayhn`-?2&LtRrRJqi z$e9cB6R^5I)0nKBCOVChq)b0*6a<(iBrEgUbMLd2QwyM9l1tP4wd6*Rg+8hbQLrMa z><(!v8)VF71+PA(ju&c^cPkk|g_YSVnr%0FVqdV|&&=LI1?TSI_l>aP_}4jga|6LZ zfO;=RpR^QIj^siH=s*rDms;{?FQ|JyfP z?B<*nksa=bl{l8xWa^)DrakaOEIV;)LMYAm{bZNl?E3O+d~A&xR(P5?buhy-afX9fxi zguOk+^iQezpk4W?_~l)05l=vp@}TxBo`5t!HPk0fq+0>~(!hE;TvR7K`f!P|5aSBU z5zt2^(W`TJ&&|TGHYNjc{hUJB7j*}+$tUov(XpkRsRKeVFUuYdk1bUqd;j3Iu*dE> z-%O3w2&LLQ!ABO^Q0(xi1fg#q<4^0+t-28Ur2pQ|d9Og5qZ|LW@0*iWuzNC#+E``O=kMU7geA;orD^_=%}7)Rg{Bj8 z!D;Or9j`HR1|HmHg&v|C(<6SC-B-xrmA`PQqA`1uo>5PGyyj~H1p2CjW;@n;!n2; z6)lt7)t*3ottbv=frs!^yi_XlER2@Rrz}kPq0~jO1ZU)YtN)>*ss66Vz)j(Uaslpv zNu2+o(tfp<*2Qaj0jhumk>|k**K3Bp@ZNJD-(h5@Ny=}+Rz~b7Y}+DA?7z-<-s=`< zRR=}8g;DrVTL9144C&|lQSr`HA->@1PfGJpy<~SdNpAwxl4g(&<*8x;d-R#X5gcDr zeX8y=&D9Y?2-MdhxB2NDm*~soUc}4s=vEo|A3nJ0?=!iPh#(s#Zx5I3Yc6@oHrQ2V zKIS&qvN^tpe+=V3WfD(mZhL%B=%}8plrHMTj|uWZlK?6RCAd-{-E-7@V~z=r86_ih zWt`4Aep_W6(hjFPo;f`)qxeWP@Nh>rzIXw|vz2(0U`Tj0R{oOE=P|m+Ry$MNEyAKx zSCE{>{9=er9N-u=9~q;rW=_LKljsmCE|&#SiZffJfE*&57d@Nc`_}|KpWt$Or0BbO zIKO)x7=Vp6z8x=%JelC|WdOtXwE)SShFI-BIJ4BA-~0skH|v5FrGg%dyK-?w+MHY# zH@SPN(BQIBuRl|X*v9s^g1SfL?ub;8A53qmV=wYu@BacJf-cn@h@|e5eKp7QNdnND zqfI`GU`3PA=Roe5)+J3=lnx1nNOd1$f|alAv;k~;hlgTu=H122x5F44+i}rgwVgd< z(j2uz9vR6sU+bQKdw3^zjJo|_t(|319$nV12@)hoa0%}2?(TUYxch?zC%ANQcXxM( z;1UQB+zIaP?)ouvX3m+ZBlFf*r>-Bp`tH5=y{mt9b+2A)y>UXXR@|4dV`#w4f{}2z zHrDlstvJc8L%#OZSJ|OXZWHYGhwnmnC#GMSpW{gb^sXZeOcA}``EF3U1=wHc_<;V? zQt_=0;2y;tBQn&g*1giFs1Of8Z*P;HR>(0YVtbHbqcxZ{@arREE~_Eo>bBpTsY{Js zZ9asqXOhVDw#`!_s#o_Iu`mD1P@fTwMaKvGCKt~0#_}ppphMmim3zO0nLHkH;IZI4 zIWgr_P+dGtPemt2A{s5oY{#9S1xfSX;KAaTnt8ttz z({AWnudVS-D;W_XIGs*5mg3P3ck5STsjFx56RC`3L+uImeGw_lzy^C8)(RT}DutY) zhPHWCF<9a>KYM)BApo#)19$Lu$bMBF9gIzrta&1@sMLlNk}25bOfG;QYW zl>BibSu`C}1oq33WYo}+L5)M8W=4YAb~$~6T>M_Xox>T4k_#Zp4kig{ozJCtP*>R% z56vZ)ore+sg0Og@bt-_SJ#C=XMv=(apzz(1T&e{hu7A2geRuG z?wU;|kGN}gl(XKSl(H^WM@mBO;wKnrx1o1LWaNHpO$*YcKYBQ*fk8VPi zY(+OzWR}?bQ@drjm$6Ddx9qoY5%rRF0U<$EKRqOOjy>Q{wz{8QyvXV{?4hU2!iB=H z!qXkCZK_Y%CXEL5w0wYaR-6>;hR$S3q-H|b-p@xhSG}3BSs~BoOaAX!_gm~8%AYA- z4J*VSNAR=^NX$TfdjjP%*aVB~D8Ayq-kHs?89N!&qm}`Ek2p*syzezA{P}$b&HdNV z%g~15rXqpbiRc#&z$-91)4U#2ZYFQra^k|t{L@Qm%gamZ&lkw+oBp487pRJ9Rb~lM zj^+hCX z3CI|S1}7xTM!jWa7`z5t=R626gf(zy4N>-nlI@J1>{Bl0kKEu|pP+|N*(7hb4~tf#YZrb!OEx&6BwNYZnMeo7cFoA%6f)Z9uZCYN ze*eMsU_F%LAu;#uQs%x_Lj3}gPW$eO(5FvLI+)`6W6!ObRq|3UD>0N*lGSoH%)t~G z+m@I70}Nwrdw!T0DS=Z#)P+8fDaj1b6GU<9LE$1y^~U?|iR9eTwYJlTwIvLASQw*D2QL9#al5p<{@s=t7J8nF-lZV;7ED7pAf39Q=FKrtKNikd%quCMcR1Yi7>KMs>D>S&Ol6arT<04^G1>={7>*hoTp$FeJ!iiFC)7had)MT+c6+V(`ipS6by#6qv zEmsX6aq>&6_H?kjOmjmn6)KdCO7~40!v&SDqE+?l3}l-Zou9UR4bM;4S(T4+`qgiC z0q^XW<$RwQpirx?$F*p2T#7FJrU8z7Fj>pg4(pkWwb^HCMXY%ju-g=iL`akvm$T@ z{remAf!}U=2VuXYh2RZ~q3gRV4M7-*&)O;7#TY0JVu?`Z+o4hD%85}-OZm@7E17f4@X}fpSEB+tbHi95UYLkc zrmW*R=J6%n+r6{Wb#h6-JW0yH-#@OpnlY#?m{+zOj;6pr4ibX&yf5?v0u@WC1<5@~ zUMuc1uZoo;KFy2qHCgDV9cp+-d3&hQ3OFfDu3>xZou-ZU=)=IcIjArlGzrBOn7EW* zC0rcgqF^mG*~At<9-3k_?ssM5m+_t(k)*3;%XUf%X2>{CF03%XyJt5?{R-osraZW$}H1tzyC-mx)r& z4HR)(!%vW}%g+VM9QLUjj|EyMU&6?H_i!E~Dm7rzIEn({%BmAZ0 zpnYg0c8h*#%~Rst7eaBEZ?N*kcS-Q7RtXBrKy}>1NZ7%_;Wtu!y031kvu*I-%Ex&D z=dZn_PLKn1)aID@pKJSh=K@(OvF1(aw88>%WfET~!{VNK)7Rmwv++Ma%!EK>icCPKKz=Bf+xQXnxyyNo*GG3z;VaG8;pYdtnPoiLW2N&O8SJRviPi$9{dr}d?jq}e1 zWU`z0fkRi~MrOaPRPI?!3vxG7jM?rM9bG$uja;rr%0G=CI!uy@aJp@)3o$8s+?Q;l z23G)2M#XPGxq6WJ=F}+Bb`A1e%Efv2y9^T0>R82yIo5w&Lp#e(e-bXlIpY>Becu-p z3s(HYw_{-eIZ1zEQGz$DFrbuWVEEAl)IomTwM${itOHA_hc2ZQIZ&B@8BZ#dQOwm% zSR4ZS?y7STCaSC=>Sn=W0jXOWDDxS;*a8yZ_?%q`Kh5rwN~t&nZJ8?kclEz1uE?f- zm-|y#ZUOn17Xbd!&`xa`^tsYW=uA4tY*YAYdY@DZ#X4xqMBx#%VogX6x&OWhxL#^P z@_kP1yfl_T{{Ok~+rDh+3u+P6yvG8RFa?9rVfP$+#p|{g;17XpH90iCSL$M z2Y11A{t=YdWDdZ!_^PlcDMeKD)hRTE=6 z4rt6Rk{+kwZ^N~`Ryrn26UZ2T9yv}a6o zthVc~W~WPupBiJBJ+q4Jj#be@z-It4-#k~pE}_A#=;WMZsA=hq?BrAxMJR9da)dHe z{?X3{oitPmH0ZA1t3=)q`X@_`%;gR{eCj@tvUN6MB+p3uw8jtkf$OmqevSqvhn*L1 zLf!-6`}KtTQB`6BtQsQAeOqaD+&{1^aD7$;)1GptL8g_+l~ri+wkPl`X+R_jCx3*M z>&D#KnH?oWSX=QuqQZdA=(ue!Zj=M;$bEQM_Ce>oM?;K$s%8a;D#;gM8bX)Ir0JPT1W(sY;^)d&U4+J9I0PcRyWTLGfx56G}YMWJ08 z&MOQ_z3woG$bT7c7n~&vvu1~S`kBtPzm7{#7q$(KX9UL=;VwX=>#uPdW`>QRx4lR_1?v{Jm&!K|-uL2M>D%o+*Y0I7q6YyESx;@xGBqw@E2Oy{ zTT1xLrYYQh6G2Tsnqu#^YRVoe(qkHfOPZn7p(9K-@Fki2*Wpdgb3dMhNUV?+cDzaJ z|6{_jjq`Ikf?+=ef}3*k{r9@Xp~>P$*I{fI6s8{uijT)1o<(9Bp^6hxT$pe__giE$ ziY&Kyj93Dn4lboukcCtGK8t5>4K%^cSQp0yxCkr_DOrEGaQ;4dv3}S&7-x@LE+>4; zJFQhMqz{X=bqrD9hO_XYhZPsQtHt`xZ#%j1ojYWI>*wGbodY>_lO$f`!(zT)!b_am zMh4$JqHTWZ(n>dGoKS1dG47KA%}q;GH0!O8`KYddOVzL{w#o=2%3 z67)+Ss!v?X3$Ab+?z48Vfw}mj9&31^R|(b;CW?@*ojQMx#7?O-q!i^XUnFMwNsI`3 zp9sW-w6@}jC6(}?ZcX9W`i`YdHkUuOW%KaF_GqXXnjDVtWIBtL6Vb3d2j5!oJmS0c z7f;TC30Q@wsOF9R^P24vZpiv$LL%S~jCNTOwqM0%sGs)YNpCf0x`G>oIRz>j>bMGP zwqZZQ2fyT+$`7KY`0yHBgR5fr2=A^Jeop7kIw-T+`F7ShKTln?d zrsM;C;K5tODUk6q;aFJKjO*yCg8Q+%v9T8fla9>P%9m#VR(wc8r+&sIfzK78@nhYO z3dNwz48$H_1GRE)ylFZ#=Zr` z_3e^*GGn21P;Gt&*0ikebEG@FN*&i#ShsiEE~{mYh~}q?DeR-;^Q^0D^WtoyJvft{ zt0EQX+P0RN##JnnK-&(*sMYSn2`H?s#ryUKAgemQmYN}#(4cS-hE0ZV@*UfM=J_$lxl4Z1Mxj&w zKeL-O;+ry)%mzCMKo9=EAy+I6_y9(`p@slM{s$gh<4X|ISk1H{-f|3-I^_?8^lvX4 z$#)y!R=zD5mS;B*U2TDS&(x32+@EB!6&{X4HbDRxYmqZq@6GE}b&;G0PLz;W7Cyi` zi||uOk3Ps~tXp3vNJ>*f1sR!wjoRhq%3AAQ@OG(*iXiPWt=V&$XZ01YrY<;W?gy=r zXlQMP4)+$t?1yzOZP>0m3Wa6dhMhL@wx10ys?RDT^9#$nS5C7h4?l{y{BoVu;{{h8 zADID`M^R2k`2J@6`k$>;ZU=T!wjz=gxAkW{w=8{-l{eF1OBum~KvX@-AwIcGm&Ap( zDM&?K*WDsJn=b+n+l7*Ikya;zP)1y}%B^JGnA#xw`OiDP_ab@q5gKAb;aa=~O|x?^ z>50uJq08+PXK7cePdhP|>A4~LBJR)CZW_RWT_{&Q->SQPXk2W)o3WO}*G&wGQ*D2# z!ae=55M}6AlV98t--ReKi_EK*YC_ZGsLuMYw7bbYS@RqaoY~x0=%yObh(IE*zSqOd zV22$Hgv=CI5bAzH3S5!F6MNrOUZ%vUE^T%KIYotP*g3LJ!|>$DijiS|{VSu8O1Ep~-dG6Vj-VjhY$|Wk z=7bV&O$9R5MhP_s!^fAnNgbeqz_<>f%W4glWn)((AB-tU%ETIvyQ=(UqAN#0SW;t$ zf(uk-N2zAG2kN#%)q{Kar8}=FLC`?g$bW16T&=7!;7t@u~dvhpWG%i zZ8G79lY=gLB>ACtpSX5}bW!DFYcDCxueAy+GP3a5{RIa4L}-Sg04sn`+NxEWo1>@K z0hNVe3#_8eqwA4Tlg<|ESm;>G2vSdy-vwn}8vH3Suc!8k<7XVc@RA^4S=N%q*E9pnxa!$B6>Fn4=^)m+|PR$#PVPS{z;rQBig7^db3j)UQ${ zkva~B1A*S~&m_wOY}#=Oek!Jx9pT(^1K^kFNBqJ`PSpVb@SVEGD6CP2w2;7aBfuTI zw(JQ2KVjAFGpVyV%XV^)n3iX4wc*9L3R(YdQENz#=^o1DbeH|u?%6bB)y(Qjs!Om9 zHCZC9-&6HX zGtd+T5!KM?R+wNFcU7?`R(C&H*~-P#4g8#5kV=tXih<_Wv#kwc5$ zhKxvSi85lpSyM5FjWfuF8%q_DQ@U^4wL2s;do?k`Um0F2{@(9?eUeipA@q19;T7}- z&JXGumG|j-hU$$eHqPX8CCdk)@y;*X&3hcdUtH_F&YsDRH>38xt6V7;1>->JyClOT z!p?8g!s-dV!Lx~AS?l?HxV)H!T7=0&#;!|Ja#=}jHH#nMUK){#x2>E*S<0=R zxhdQGHk21uG04eiGoFC7)nb7LdS%O@B4{zsw0@EfQIGJ`e5Ch@RX$(jmmz0#xfdX3 zgko?`IB9+@4=rnSf}AA6gnzR%MB#{)ayU~Of~Qs}Q*WFHekhm@&)ucvp+#_7KfxZN zMQ~X^{oeB3xN$@~JfUQ@DU&?*+rh!(fXfPWp0;7a|5R%`MrwAO27L}ZOttqM8cVQf zCDfbuh_ffSdZF5S=(QXl|6-sd-eF-{a>;%o6Uo-0gIjV!3s_9s@gVdselN@AyirB0 z^lWz3l1cyb;Q56up< zV!igPhsLfTop6CHRgGIyuJjGNy9sCJeG;$tAO?~=&J510Ev01o$sQTENNdgZFqsCM z!-1nFHyqthBb^^Rf?Tx8nEksRR(F}se{z|?xES1DT&~G z0+|_KKKflkE2w+71P#nZa#$W+TUgawknX5*&F`pQD*K5xE$#-NTa2~4d2z0{Yp#+{ zST)a81Te_eB?r6NCs3YVWtL;I{b4X~xaBu@f8sS}K~nEGVe9!&)t7F!lBO>LiA1j8 zRND5HAjPF3j$F?VaN=_5K?Y|m5uoLbla~{RsEDAKkaneJijL;jelNO&k(3_pSkX1& z-1KK^#NTrY`<#|vu=!PAazp~pgK@HHi!`Fnp_1WmTi2SXCLTpEXV1d8(>)jwr@R{&lBDu0w55PJ{zk+APhx(VJ2`M4IJ2C=QUL z)AT7VZ%i@&@ypgNG>}$r@4fD}a-1mVPw?-zBmtwR?F6HG2g1C@JWnK@WtzLAT2|0T zzd&*$f_43S2g#IUTv!eA8l?`VZCsdH)P1 zFsi9N@gWeXxi<0FVr}4MRW@9qiGChxCw)}Fjebs+>$EOxeZK_jw;sk`>Rpk2Fl%8V zU>$k{tk4o;;;#%8`Z}~=$4?2MBmb<3&5vD$3bwtdy5g0YXUYLT>=leqD!fB;yV1Wq zn3bHYiU{+zhSpV6-W4LEV!5s0th297dhA-VVNU}RfG(wyfgc)`L_5w|t~K83$)60= zh=!4u5fvCAZB)+@Mr&WTc6Ot(4uOsrgGTAM=llhpuZ_q2&PYFw`Jpv={iIC^>b|D6 zUkqZv|LJ$`2&*$mkoB9q)E!*rdP0aD*4QS*a;9OE$uhzzAig^Kp<0LI-*Zi<>l-bF zNXrhG`uiiMdG_O|9PPvfixjon^+kyM&cP|;zc!q#NLQrL=QhOgd$9`WESeQW7WuH#QvrLGO4Dq zGvp~|SVR$Of-(z+fkvLW&W>17CKa!(OC%ONM!%d}TnY+{*jlM;_bUXh@8peDgU0Le zmedVg%^0fYB&;T<$&;0pXp@r@M;FSxPK~bBU+F9H!ta))EZo&^E2pp6P0_M{N&uh6 zui~FJ_v|Lt-MQLbXPMn~u9^%1YlU9AP(f@j8|h7D2RKQtj?Q(etB%v0Th+4wnr}aY zB=>mszT4bAf9s~TA^z6A7bu386$=Z!%-phmCRxmU5oLq3TrEZBDa*3b8e-)W&`QO% z0EKPfb7|LUjmBPootzkF5?B?H)J>Z5*70t=dfa_eC9s(*#y0)&1NQL+fWYj=(|2=w z{p%R*TSWhYpW(Q*rap zf8(&#g|Ku=_uDQD$O?-@F7L)_KcJA1Af44yMD@^3{PD~<)a9;dPBiUms9b61hE)9E z1up6>|I|g%+*_%?*)t7)o6TfDyu#|$KZ(Hr5Lvum6E;j@S)Ib37>8kJ?0rludyk|H zTx{6tUruL|9D*1Zl7gM~3Ar*4lNJa!cXa}4m_FU5=28DHQPXn}B#pDOml=aKiDh8i zKX;vEWZItxO)@a=tCC5|ogC0#-8O~9w|3CkhFNlXmqExDysw;P$l4VvS@wNyg7c9B zG^ZJ9f)+#hiaNq+EZ>OSuB6p$*y!63$hHq^+k4$G@2vpK$nUmHdxl82fc2M3Q{uk>*i; zZSxaJov_w6tO;d7ED1#IM!Q;jHhnDyCUJvm!O^YikM_~#o{)C8Z4&P_h1nXG1Z>Gg zJ3X|Iy+)Mz#jud=xWwn$K~ipShHh_$O>c%SRfV~4h9{O*<`7I!ZHQ;AokRZ~peMA- zrcrkdL$E;&BQmpf{%g=p{+7`6JHptGt#br|3u+1R-vVac_Oe<{_9w(Mw$3pKA*g=~ zqJLe((Edc%E@~*=q`096yHkY*4^bQMHBlF~VD@fP- z4hvQT-@yL+zXm}A2znXTl7|1T{$rfs#@kv^w*T9tzh6#HJ!63VH4A^IHDgE3fsaf_ z6xLl1L%^9PX5T{&Uyi(d&2fbM?6{2$bXJjR5t^TKE7?hoyK>8jr~Un5bP zlY&NR7f=1tHGe;RPUgq8`=ID0?sDr*=3mDhU%@RqHx5&U0!ukA6%GFy{{Tk None: """Initialise the atom site with default descriptor values.""" super().__init__() + # Set when a Wyckoff letter is assigned without a parent context + # (e.g. create() before the atom is added); the update flow then + # validates it once the parent structure is available. + self._wyckoff_letter_needs_validation = False + # Wyckoff-detection baselines (None until first detection); + # compared in the update flow to decide whether to re-detect. + self._wyckoff_coord_baseline: tuple[float, float, float] | None = None + self._wyckoff_key_baseline: tuple[str, str | None] | None = None self._label = StringDescriptor( name='label', @@ -123,7 +134,9 @@ def __init__(self) -> None: ), value_spec=AttributeSpec( default=self._wyckoff_letter_default_value, - validator=MembershipValidator(allowed=self._wyckoff_letter_allowed_values), + validator=PermissiveMembershipValidator( + allowed=lambda: self._wyckoff_letter_allowed_values, + ), ), cif_handler=CifHandler( names=[ @@ -133,6 +146,17 @@ def __init__(self) -> None: ] ), ) + self._multiplicity = IntegerDescriptor( + name='multiplicity', + description='Site multiplicity derived from the Wyckoff ' + 'position; None for an untabulated space group.', + display_handler=DisplayHandler( + display_name='Mult.', + latex_name='Mult.', + ), + value_spec=AttributeSpec(default=None, allow_none=True), + cif_handler=CifHandler(names=['_atom_site.site_symmetry_multiplicity']), + ) self._occupancy = Parameter( name='occupancy', description='Occupancy of the atom site, representing the ' @@ -196,21 +220,39 @@ def _type_symbol_allowed_values(self) -> list[str]: """ return list({key[1] for key in DATABASE['Isotopes']}) + def _resolve_structure_space_group(self) -> object | None: + """ + Return the parent structure's space-group category, or ``None``. + + Walks ``AtomSite`` → atom-sites collection → structure; returns + ``None`` when any link is missing (no parent context yet). + """ + collection = getattr(self, '_parent', None) + structure = getattr(collection, '_parent', None) if collection is not None else None + return getattr(structure, 'space_group', None) if structure is not None else None + @property def _wyckoff_letter_allowed_values(self) -> list[str]: """ - Return allowed Wyckoff-letter symbols. + Allowed Wyckoff letters for the current space group. Returns ------- list[str] - Currently a hard-coded placeholder list. - """ - # TODO: Need to now current space group. How to access it? Via - # parent Cell? Then letters = - # list(SPACE_GROUPS[62, 'cab']['Wyckoff_positions'].keys()) - # Temporarily return hardcoded list: - return ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] + ``['', *letters]`` for a tabulated space group (empty first, + so an unset letter is valid); ``[]`` when there is no parent + context or the space group is untabulated. + """ + space_group = self._resolve_structure_space_group() + if space_group is None: + return [] + positions = ecr.space_group_wyckoff_table( + space_group.name_h_m.value, + space_group.it_coordinate_system_code.value, + ) + if positions is None: + return [] + return ['', *positions] @property def _wyckoff_letter_default_value(self) -> str: @@ -220,10 +262,11 @@ def _wyckoff_letter_default_value(self) -> str: Returns ------- str - First element of the allowed values list. + The first allowed value (empty string), or ``''`` when no + letters are allowed. """ - # TODO: What to pass as default? - return self._wyckoff_letter_allowed_values[0] + allowed = self._wyckoff_letter_allowed_values + return allowed[0] if allowed else '' def _convert_adp_values(self, old_type: str, new_type: str) -> None: """ @@ -444,7 +487,34 @@ def wyckoff_letter(self) -> StringDescriptor: @wyckoff_letter.setter def wyckoff_letter(self, value: str) -> None: - self._wyckoff_letter.value = value + if self._resolve_structure_space_group() is None: + # No parent context yet (e.g. create() before the atom is + # added): store the raw value and defer validation to the + # update flow, which resolves it once context is available. + self._wyckoff_letter_needs_validation = True + self._wyckoff_letter._set_value_from_minimizer(value) + else: + self._wyckoff_letter.value = value + + @property + def multiplicity(self) -> IntegerDescriptor: + """ + Read-only site multiplicity derived from the Wyckoff position. + + Populated by Wyckoff detection; ``value`` is ``None`` when the + space group is untabulated. There is no public setter. + """ + return self._multiplicity + + def _set_wyckoff_letter_detected(self, letter: str) -> None: + """ + Set the auto-detected Wyckoff letter, bypassing validation. + + Modelled on ``_set_value_from_minimizer``: detection supplies a + trusted letter, written directly rather than re-validated + against the (dynamic) allowed-letters set. + """ + self._wyckoff_letter._set_value_from_minimizer(letter) @property def fract_x(self) -> Parameter: @@ -552,47 +622,150 @@ def __init__(self) -> None: # Private helper methods # ------------------------------------------------------------------ - def _apply_atomic_coordinates_symmetry_constraints(self) -> None: + def _apply_atomic_coordinates_symmetry_constraints( + self, *, called_by_minimizer: bool = False + ) -> None: """ - Apply symmetry rules to fractional coordinates of every site. + Detect Wyckoff letters and snap coordinates to symmetry. - Uses the parent structure's space-group symbol, IT coordinate - system code and each atom's Wyckoff letter. Atoms without a - Wyckoff letter are silently skipped. Coordinates fully - determined by site symmetry are flagged as - ``symmetry_constrained`` so they cannot be marked refinable. + For each atom: resolve any pending no-context Wyckoff letter; + (re)detect the letter when it is empty or the coordinates / + space-group key changed (skipped under a minimizer); snap + coordinates to the selected orbit representative; and record the + multiplicity and constrained-axis flags. Atoms in an untabulated + space group keep their stored letter unvalidated, with no + multiplicity or constraints. + + Parameters + ---------- + called_by_minimizer : bool, default=False + When True (per fit iteration), skip re-detection and + warnings; only the silent coordinate snap runs. """ structure = self._parent - space_group_name = structure.space_group.name_h_m.value - space_group_coord_code = structure.space_group.it_coordinate_system_code.value + name_hm = structure.space_group.name_h_m.value + coord_code = structure.space_group.it_coordinate_system_code.value + supported = ecr.space_group_wyckoff_table(name_hm, coord_code) is not None for atom in self._items: - wl = atom.wyckoff_letter.value - if not wl: - # TODO: Decide how to handle this case - self._clear_fract_symmetry_constrained(atom) - continue - dummy_atom = { - 'fract_x': atom.fract_x.value, - 'fract_y': atom.fract_y.value, - 'fract_z': atom.fract_z.value, - } - ecr.apply_atom_site_symmetry_constraints( - atom_site=dummy_atom, - name_hm=space_group_name, - coord_code=space_group_coord_code, - wyckoff_letter=wl, + if atom._wyckoff_letter_needs_validation: + self._resolve_pending_wyckoff_letter(atom, name_hm) + if supported: + self._detect_and_snap_atom( + atom, name_hm, coord_code, called_by_minimizer=called_by_minimizer + ) + else: + self._mark_atom_untabulated( + atom, (name_hm, coord_code), called_by_minimizer=called_by_minimizer + ) + + @staticmethod + def _resolve_pending_wyckoff_letter(atom: AtomSite, name_hm: str) -> None: + """ + Validate a deferred no-context Wyckoff letter; raise if invalid. + """ + stored = atom.wyckoff_letter.value + allowed = atom._wyckoff_letter_allowed_values + if allowed and stored not in allowed: + msg = ( + f'Invalid Wyckoff letter {stored!r} for space group ' + f'{name_hm!r}; allowed letters: {allowed}' ) - constrained_flags = ecr.atom_site_symmetry_constrained_flags( - name_hm=space_group_name, - coord_code=space_group_coord_code, - wyckoff_letter=wl, + raise ValueError(msg) + atom._wyckoff_letter_needs_validation = False + + def _mark_atom_untabulated( + self, + atom: AtomSite, + key: tuple[str, str | None], + *, + called_by_minimizer: bool, + ) -> None: + """Handle an atom whose space group is absent from the table.""" + atom._multiplicity.value = None + self._clear_fract_symmetry_constrained(atom) + if atom.wyckoff_letter.value and not called_by_minimizer: + log.warning( + f'Wyckoff letter of {atom.label.value} is stored but not ' + f'validated because the space group is untabulated' ) - atom.fract_x.value = dummy_atom['fract_x'] - atom.fract_y.value = dummy_atom['fract_y'] - atom.fract_z.value = dummy_atom['fract_z'] - atom._fract_x._set_symmetry_constrained(value=constrained_flags['fract_x']) - atom._fract_y._set_symmetry_constrained(value=constrained_flags['fract_y']) - atom._fract_z._set_symmetry_constrained(value=constrained_flags['fract_z']) + atom._wyckoff_coord_baseline = (atom.fract_x.value, atom.fract_y.value, atom.fract_z.value) + atom._wyckoff_key_baseline = key + + def _detect_and_snap_atom( + self, + atom: AtomSite, + name_hm: str, + coord_code: str | None, + *, + called_by_minimizer: bool, + ) -> None: + """ + Detect (if triggered) and snap one atom to its Wyckoff position. + """ + key = (name_hm, coord_code) + letter_before = atom.wyckoff_letter.value + coords = (atom.fract_x.value, atom.fract_y.value, atom.fract_z.value) + # A ``None`` baseline marks the first population + # (create/load), not a later edit. Treat coordinates or the + # space-group key as "changed" only against an existing + # baseline, so an explicit initial letter is preserved (routed + # to ``wyckoff_position_info`` below) instead of being + # overwritten by all-letter detection. The ADR requires a + # user-supplied letter to persist until a genuine later + # coordinate or space-group-key edit. + coords_changed = atom._wyckoff_coord_baseline is not None and any( + abs(a - b) > ecr._WYCKOFF_DETECTION_TOL + for a, b in zip(coords, atom._wyckoff_coord_baseline, strict=True) + ) + key_changed = atom._wyckoff_key_baseline is not None and atom._wyckoff_key_baseline != key + detect = (not called_by_minimizer) and (not letter_before or coords_changed or key_changed) + if detect: + position = ecr.detect_wyckoff_position(name_hm, coord_code, coords) + if position is not None and letter_before and position.letter != letter_before: + log.warning( + f'change moved the Wyckoff letter of {atom.label.value} ' + f'from {letter_before} to {position.letter}' + ) + if position is not None: + atom._set_wyckoff_letter_detected(position.letter) + elif letter_before: + position = ecr.wyckoff_position_info( + name_hm, coord_code, letter_before, fract_xyz=coords + ) + else: + position = None + + if position is None or position.coord_template is None: + atom._multiplicity.value = None + self._clear_fract_symmetry_constrained(atom) + atom._wyckoff_coord_baseline = coords + atom._wyckoff_key_baseline = key + return + + atom._multiplicity.value = position.multiplicity + snapped, flags = ecr.snap_to_wyckoff_template(position.coord_template, coords) + atom.fract_x.value = snapped[0] + atom.fract_y.value = snapped[1] + atom.fract_z.value = snapped[2] + atom._fract_x._set_symmetry_constrained(value=flags['fract_x']) + atom._fract_y._set_symmetry_constrained(value=flags['fract_y']) + atom._fract_z._set_symmetry_constrained(value=flags['fract_z']) + moved = any( + abs(s - c) > ecr._WYCKOFF_DETECTION_TOL for s, c in zip(snapped, coords, strict=True) + ) + if moved and not called_by_minimizer: + if not detect: + log.warning( + f'coordinates of {atom.label.value} did not fit letter ' + f'{position.letter} and were adjusted' + ) + elif letter_before and position.letter == letter_before: + log.warning( + f'coordinates of {atom.label.value} were adjusted to satisfy ' + f'Wyckoff letter {position.letter}' + ) + atom._wyckoff_coord_baseline = snapped + atom._wyckoff_key_baseline = key @staticmethod def _clear_fract_symmetry_constrained(atom: AtomSite) -> None: @@ -683,10 +856,11 @@ def _update( ---------- called_by_minimizer : bool, default=False Whether the update was triggered by the fitting minimizer. - Currently unused. + When True, Wyckoff re-detection and warnings are skipped; + only the silent coordinate snap runs. """ - del called_by_minimizer - - self._apply_atomic_coordinates_symmetry_constraints() + self._apply_atomic_coordinates_symmetry_constraints( + called_by_minimizer=called_by_minimizer + ) self._apply_adp_symmetry_constraints() self._sync_iso_from_aniso() diff --git a/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/__init__.py b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/__init__.py new file mode 100644 index 000000000..3808d3075 --- /dev/null +++ b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from easydiffraction.datablocks.structure.categories.space_group_wyckoff.default import ( + SpaceGroupWyckoff, +) +from easydiffraction.datablocks.structure.categories.space_group_wyckoff.default import ( + SpaceGroupWyckoffCollection, +) diff --git a/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py new file mode 100644 index 000000000..3c6256567 --- /dev/null +++ b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py @@ -0,0 +1,206 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Derived space-group Wyckoff category. + +Defines :class:`SpaceGroupWyckoff` items and the read-only +:class:`SpaceGroupWyckoffCollection`. Rows are derived from the bundled +space-group table for the structure's current space group; they are not +user-edited. ``Structure`` rebuilds the collection when the space group +changes via +:meth:`SpaceGroupWyckoffCollection._replace_from_space_group`. +""" + +from __future__ import annotations + +from typing import override + +from easydiffraction.core.category import CategoryCollection +from easydiffraction.core.category import CategoryItem +from easydiffraction.core.display_handler import DisplayHandler +from easydiffraction.core.metadata import TypeInfo +from easydiffraction.core.validation import AttributeSpec +from easydiffraction.core.variable import IntegerDescriptor +from easydiffraction.core.variable import StringDescriptor +from easydiffraction.crystallography import crystallography as ecr +from easydiffraction.datablocks.structure.categories.space_group_wyckoff.factory import ( + SpaceGroupWyckoffFactory, +) +from easydiffraction.io.cif.handler import CifHandler + +_READ_ONLY_MESSAGE = ( + 'space_group_wyckoff is derived from the space group and is read-only; ' + 'it is rebuilt automatically when the space group changes.' +) + + +class SpaceGroupWyckoff(CategoryItem): + """ + A single Wyckoff position of the space group (all fields read-only). + """ + + _category_code = 'space_group_Wyckoff' + _category_entry_name = 'id' + + def __init__(self) -> None: + """Initialise an empty Wyckoff-position row.""" + super().__init__() + + self._id = StringDescriptor( + name='id', + description='Identifier of the Wyckoff position.', + display_handler=DisplayHandler(display_name='ID', latex_name='ID'), + value_spec=AttributeSpec(default=''), + cif_handler=CifHandler(names=['_space_group_Wyckoff.id']), + ) + self._letter = StringDescriptor( + name='letter', + description='Wyckoff letter of the position.', + display_handler=DisplayHandler(display_name='Letter', latex_name='Letter'), + value_spec=AttributeSpec(default=''), + cif_handler=CifHandler(names=['_space_group_Wyckoff.letter']), + ) + self._multiplicity = IntegerDescriptor( + name='multiplicity', + description='Multiplicity of the Wyckoff position.', + display_handler=DisplayHandler(display_name='Multiplicity', latex_name='Multiplicity'), + value_spec=AttributeSpec(default=0), + cif_handler=CifHandler(names=['_space_group_Wyckoff.multiplicity']), + ) + self._site_symmetry = StringDescriptor( + name='site_symmetry', + description='Site-symmetry symbol of the Wyckoff position.', + display_handler=DisplayHandler( + display_name='Site symmetry', latex_name='Site symmetry' + ), + value_spec=AttributeSpec(default=''), + cif_handler=CifHandler(names=['_space_group_Wyckoff.site_symmetry']), + ) + self._coords_xyz = StringDescriptor( + name='coords_xyz', + description='Coordinates of the Wyckoff orbit.', + display_handler=DisplayHandler(display_name='Coordinates', latex_name='Coordinates'), + value_spec=AttributeSpec(default=''), + cif_handler=CifHandler(names=['_space_group_Wyckoff.coords_xyz']), + ) + + @property + def id(self) -> StringDescriptor: + """Read-only Wyckoff-position identifier.""" + return self._id + + @property + def letter(self) -> StringDescriptor: + """Read-only Wyckoff letter.""" + return self._letter + + @property + def multiplicity(self) -> IntegerDescriptor: + """Read-only multiplicity.""" + return self._multiplicity + + @property + def site_symmetry(self) -> StringDescriptor: + """Read-only site-symmetry symbol.""" + return self._site_symmetry + + @property + def coords_xyz(self) -> StringDescriptor: + """Read-only orbit coordinates.""" + return self._coords_xyz + + +@SpaceGroupWyckoffFactory.register +class SpaceGroupWyckoffCollection(CategoryCollection): + """ + Read-only collection of derived Wyckoff positions. + """ + + type_info = TypeInfo( + tag='default', + description='Derived space-group Wyckoff table', + ) + + def __init__(self) -> None: + """Initialise an empty derived Wyckoff collection.""" + super().__init__(item_type=SpaceGroupWyckoff) + + @override + def add(self, item: object) -> None: + """ + Reject public mutation; the collection is derived (read-only). + """ + raise ValueError(_READ_ONLY_MESSAGE) + + @override + def create(self, **kwargs: object) -> None: + """ + Reject public mutation; the collection is derived (read-only). + """ + raise ValueError(_READ_ONLY_MESSAGE) + + @override + def remove(self, name: str) -> None: + """ + Reject public mutation; the collection is derived (read-only). + """ + raise ValueError(_READ_ONLY_MESSAGE) + + @override + def __setitem__(self, name: str, item: object) -> None: + """ + Reject item assignment; the collection is derived (read-only). + """ + raise ValueError(_READ_ONLY_MESSAGE) + + @override + def __delitem__(self, name: str) -> None: + """ + Reject item deletion; the collection is derived (read-only). + """ + raise ValueError(_READ_ONLY_MESSAGE) + + @override + def from_cif(self, block: object) -> None: + """ + Ignore incoming CIF values for this derived category. + + The Wyckoff table is derived from the structure's space group + and is never read back from a CIF file: any + ``_space_group_Wyckoff.*`` loop in incoming CIF (for example a + hand-edited project file) is discarded and the table is rebuilt + from the space group on the next update. + """ + return + + def _replace_from_space_group(self) -> None: + """ + Rebuild rows from the parent structure's space group. + + Repopulates from the bundled Wyckoff table and adopts the new + rows via ``_adopt_items``, which rebuilds the name index and + parent links so a stale key lookup cannot survive a space-group + change. Leaves the collection empty for an absent/untabulated + space group. + """ + structure = getattr(self, '_parent', None) + if structure is None: + self._adopt_items([]) + return + name_hm = structure.space_group.name_h_m.value + coord_code = structure.space_group.it_coordinate_system_code.value + positions = ecr.space_group_wyckoff_table(name_hm, coord_code) + if not positions: + self._adopt_items([]) + return + rows = [] + for letter, position in positions.items(): + multiplicity = int(position['multiplicity']) + row = self._item_type() + row._id.value = f'{multiplicity}{letter}' + row._letter.value = letter + row._multiplicity.value = multiplicity + row._site_symmetry.value = str(position['site_symmetry']) + row._coords_xyz.value = ' '.join(position['coords_xyz']) + rows.append(row) + self._adopt_items(rows) diff --git a/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/factory.py b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/factory.py new file mode 100644 index 000000000..2e3ea0980 --- /dev/null +++ b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/factory.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Space-group Wyckoff factory — delegates entirely to ``FactoryBase``. +""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.core.factory import FactoryBase + + +class SpaceGroupWyckoffFactory(FactoryBase): + """Create space-group Wyckoff collections by tag.""" + + _default_rules: ClassVar[dict] = { + frozenset(): 'default', + } diff --git a/src/easydiffraction/datablocks/structure/item/base.py b/src/easydiffraction/datablocks/structure/item/base.py index 8d8d27928..704a98c9c 100644 --- a/src/easydiffraction/datablocks/structure/item/base.py +++ b/src/easydiffraction/datablocks/structure/item/base.py @@ -19,6 +19,12 @@ from easydiffraction.datablocks.structure.categories.geom.factory import GeomFactory from easydiffraction.datablocks.structure.categories.space_group import SpaceGroup from easydiffraction.datablocks.structure.categories.space_group.factory import SpaceGroupFactory +from easydiffraction.datablocks.structure.categories.space_group_wyckoff import ( + SpaceGroupWyckoffCollection, +) +from easydiffraction.datablocks.structure.categories.space_group_wyckoff.factory import ( + SpaceGroupWyckoffFactory, +) from easydiffraction.utils.logging import console from easydiffraction.utils.utils import render_cif @@ -43,6 +49,8 @@ def __init__( self._atom_site_aniso = AtomSiteAnisoFactory.create(self._atom_site_aniso_type) self._geom_type: str = GeomFactory.default_tag() self._geom = GeomFactory.create(self._geom_type) + self._space_group_wyckoff_type: str = SpaceGroupWyckoffFactory.default_tag() + self._space_group_wyckoff = SpaceGroupWyckoffFactory.create(self._space_group_wyckoff_type) self._identity.datablock_entry_name = lambda: self.name # ------------------------------------------------------------------ @@ -180,6 +188,13 @@ def geom(self, new: Geom) -> None: """ self._geom = new + @property + def space_group_wyckoff(self) -> SpaceGroupWyckoffCollection: + """ + Read-only Wyckoff table derived from the current space group. + """ + return self._space_group_wyckoff + # ------------------------------------------------------------------ # Private methods # ------------------------------------------------------------------ @@ -230,6 +245,7 @@ def _update_categories( if not called_by_minimizer and not self._need_categories_update: return + self._space_group_wyckoff._replace_from_space_group() self._sync_atom_site_aniso() for category in self.categories: @@ -237,6 +253,16 @@ def _update_categories( self._need_categories_update = False + def _serializable_categories(self) -> list: + """ + Project-CIF categories (excludes the derived Wyckoff table). + """ + return [ + category + for category in self.categories + if not isinstance(category, SpaceGroupWyckoffCollection) + ] + # ------------------------------------------------------------------ # Public methods # ------------------------------------------------------------------ diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index 61bd915c6..8bfed31a4 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -2305,24 +2305,25 @@ def _bragg_row_height_pixels(plot_spec: PowderMeasVsCalcSpec) -> float: def _baseline_non_bragg_row_heights( cls, plot_spec: PowderMeasVsCalcSpec, - row_count: int, *, - has_bragg_ticks: bool, has_residual: bool, ) -> tuple[float, float | None]: - """Return baseline main and residual row heights in pixels.""" + """ + Return fixed main and residual row heights in pixels. + + Anchored to the reference three-row layout so the main and + residual rows keep their pixel height regardless of which rows + are shown; ``_composite_figure_height`` adapts instead. + """ baseline_height = cls._base_composite_height_pixels(plot_spec) plot_area_height = cls._composite_plot_area_height(baseline_height) - available_row_pixels = plot_area_height * cls._subplot_available_height_fraction(row_count) - baseline_bragg_pixels = float( - cls._bragg_tick_symbol_height_pixels() if has_bragg_ticks else 0 - ) - non_bragg_pixels = max(available_row_pixels - baseline_bragg_pixels, 1.0) + available_row_pixels = plot_area_height * cls._subplot_available_height_fraction(3) + non_bragg_pixels = max(available_row_pixels - cls._bragg_tick_symbol_height_pixels(), 1.0) + main_pixels = non_bragg_pixels / (1.0 + plot_spec.residual_height_fraction) if not has_residual: - return non_bragg_pixels, None + return main_pixels, None - main_pixels = non_bragg_pixels / (1.0 + plot_spec.residual_height_fraction) residual_pixels = main_pixels * plot_spec.residual_height_fraction return main_pixels, residual_pixels @@ -2334,8 +2335,6 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi row_count = 1 + int(has_bragg_ticks) + int(has_residual) main_row_height, residual_row_height = PlotlyPlotter._baseline_non_bragg_row_heights( plot_spec=plot_spec, - row_count=row_count, - has_bragg_ticks=has_bragg_ticks, has_residual=has_residual, ) row_heights = [main_row_height] @@ -2359,22 +2358,20 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi ) @classmethod - def _composite_figure_height( - cls, - plot_spec: PowderMeasVsCalcSpec, - layout: PowderCompositeRows, - ) -> float: - """Return figure height for Bragg row growth.""" - base_pixels = cls._base_composite_height_pixels(plot_spec) - phase_count = len(plot_spec.bragg_tick_sets) - if phase_count <= 1: - return base_pixels - - added_bragg_pixels = float((phase_count - 1) * cls._bragg_tick_symbol_height_pixels()) - growth_pixels = added_bragg_pixels / cls._subplot_available_height_fraction( - layout.row_count - ) - return base_pixels + growth_pixels + def _composite_figure_height(cls, layout: PowderCompositeRows) -> float: + """ + Return figure height matching the row pixel heights. + + Each entry in ``layout.row_heights`` is an absolute pixel + target. Plotly distributes the plot area across rows by + fraction, so the figure height is the row-pixel sum scaled up + for the inter-row spacing, plus the vertical margins. The main + and residual rows stay fixed while the Bragg row (and the + figure) grow with the phase count. + """ + row_pixels = sum(layout.row_heights) + plot_area_height = row_pixels / cls._subplot_available_height_fraction(layout.row_count) + return plot_area_height + COMPOSITE_MARGIN_TOP + COMPOSITE_MARGIN_BOTTOM @classmethod def _get_main_intensity_range(cls, plot_spec: PowderMeasVsCalcSpec) -> tuple[float, float]: @@ -2698,7 +2695,7 @@ def _configure_powder_composite_layout( layout: PowderCompositeRows, ) -> None: fig.update_layout( - height=self._composite_figure_height(plot_spec, layout), + height=self._composite_figure_height(layout), margin={ 'autoexpand': True, 'r': COMPOSITE_MARGIN_RIGHT, diff --git a/src/easydiffraction/io/cif/iucr_writer.py b/src/easydiffraction/io/cif/iucr_writer.py index b0d5d9513..6db8aae8f 100644 --- a/src/easydiffraction/io/cif/iucr_writer.py +++ b/src/easydiffraction/io/cif/iucr_writer.py @@ -171,6 +171,7 @@ def _write_sc_block( _write_cell_section(lines, structure) _write_space_group_section(lines, structure) _write_symmetry_operations_section(lines, structure) + _write_space_group_wyckoff_section(lines, structure) _write_diffrn_section(lines, experiment) _write_wavelength_section(lines, experiment) _write_atom_site_sections(lines, structure) @@ -222,6 +223,53 @@ def _write_symmetry_operations_section(lines: list[str], structure: object) -> N _write_loop(lines, loop.tags, loop.rows) +def _write_space_group_wyckoff_section(lines: list[str], structure: object) -> None: + """ + Append the derived space-group Wyckoff-position loop. + + This loop is report-only: it summarises every Wyckoff position of + the structure's space group. ``coords_xyz`` reports the + representative orbit coordinate (the first orbit member) to keep + loop cells compact. + """ + positions = list(_collection_values(getattr(structure, 'space_group_wyckoff', None))) + if not positions: + return + + rows = [ + ( + _attribute_descriptor(position, 'id'), + _attribute_descriptor(position, 'letter'), + _attribute_descriptor(position, 'multiplicity'), + _attribute_descriptor(position, 'site_symmetry'), + _wyckoff_representative_coord(position), + ) + for position in positions + ] + _section(lines, 'Wyckoff positions') + _write_loop( + lines, + ( + '_space_group_Wyckoff.id', + '_space_group_Wyckoff.letter', + '_space_group_Wyckoff.multiplicity', + '_space_group_Wyckoff.site_symmetry', + '_space_group_Wyckoff.coords_xyz', + ), + rows, + ) + + +def _wyckoff_representative_coord(position: object) -> str: + """ + Return the representative (first) orbit coordinate of a position. + """ + coords = _attribute_value(position, 'coords_xyz') + if not coords: + return '?' + return str(coords).split()[0] + + def _write_diffrn_section(lines: list[str], experiment: object) -> None: """Append diffraction metadata.""" diffrn = getattr(experiment, 'diffrn', None) @@ -449,6 +497,7 @@ def _write_powder_phase_block(phase: _PowderPhase) -> str: _write_cell_section(lines, phase.structure) _write_space_group_section(lines, phase.structure) _write_symmetry_operations_section(lines, phase.structure) + _write_space_group_wyckoff_section(lines, phase.structure) _write_atom_site_sections(lines, phase.structure) _write_atom_site_aniso_sections(lines, phase.structure) _write_powder_phase_reference_section(lines, phase) @@ -874,6 +923,7 @@ def _atom_site_tags(family: str) -> tuple[str, ...]: '_atom_site.ADP_type', f'_atom_site.{family}_iso_or_equiv', '_atom_site.Wyckoff_symbol', + '_atom_site.site_symmetry_multiplicity', ) @@ -889,6 +939,7 @@ def _atom_site_row(atom_site: object) -> tuple[object, ...]: _attribute_descriptor(atom_site, 'adp_type'), _attribute_descriptor(atom_site, 'adp_iso'), _attribute_descriptor(atom_site, 'wyckoff_letter'), + _attribute_descriptor(atom_site, 'multiplicity'), ) diff --git a/src/easydiffraction/report/fit_plot.py b/src/easydiffraction/report/fit_plot.py index 1f60fc677..3ffe55df0 100644 --- a/src/easydiffraction/report/fit_plot.py +++ b/src/easydiffraction/report/fit_plot.py @@ -105,16 +105,19 @@ def fit_plot_ranges(fit_data: dict[str, Any]) -> dict[str, float]: def fit_plot_geometry(fit_data: dict[str, Any]) -> dict[str, float]: - """Return Plotly-matched pgfplots axis geometry.""" + """ + Return Plotly-matched pgfplots axis geometry. + + Row heights are anchored to the reference three-row layout so the + main and residual panels keep a fixed centimetre height; the axis + stack grows or shrinks with the rows shown, matching the interactive + composite figure. + """ bragg_tick_sets = fit_data.get('bragg_tick_sets') or [] has_bragg_ticks = bool(bragg_tick_sets) has_residual = _has_residual(fit_data) row_count = 1 + int(has_bragg_ticks) + int(has_residual) - main_pixels, residual_pixels = _non_bragg_row_heights( - row_count=row_count, - has_bragg_ticks=has_bragg_ticks, - has_residual=has_residual, - ) + main_pixels, residual_pixels = _non_bragg_row_heights(has_residual=has_residual) row_heights = [main_pixels] if has_bragg_ticks: @@ -122,16 +125,15 @@ def fit_plot_geometry(fit_data: dict[str, Any]) -> dict[str, float]: if has_residual and residual_pixels is not None: row_heights.append(residual_pixels) - height_sum = sum(row_heights) - stack_height = _FIGURE_AXIS_WIDTH_CM * _FIGURE_AXIS_HEIGHT_TO_WIDTH - row_area_height = stack_height * _subplot_available_height_fraction(row_count) - scaled_heights = [row_area_height * row_height / height_sum for row_height in row_heights] + cm_per_pixel = _figure_cm_per_pixel() + row_heights_cm = [row_height * cm_per_pixel for row_height in row_heights] + plot_area_cm = sum(row_heights_cm) / _subplot_available_height_fraction(row_count) return { 'axis_width_cm': _FIGURE_AXIS_WIDTH_CM, - 'main_height_cm': scaled_heights[0], - 'bragg_height_cm': scaled_heights[1] if has_bragg_ticks else 0.0, - 'residual_height_cm': scaled_heights[-1] if has_residual else 0.0, - 'vertical_sep_cm': _vertical_sep_cm(stack_height), + 'main_height_cm': row_heights_cm[0], + 'bragg_height_cm': row_heights_cm[1] if has_bragg_ticks else 0.0, + 'residual_height_cm': row_heights_cm[-1] if has_residual else 0.0, + 'vertical_sep_cm': _vertical_sep_cm(plot_area_cm), } @@ -234,25 +236,36 @@ def _has_residual(fit_data: dict[str, Any]) -> bool: return isinstance(series, dict) and 'diff' in series -def _non_bragg_row_heights( - *, - row_count: int, - has_bragg_ticks: bool, - has_residual: bool, -) -> tuple[float, float | None]: +def _non_bragg_row_heights(*, has_residual: bool) -> tuple[float, float | None]: + """ + Return fixed main and residual row heights in pixels. + + Anchored to the reference three-row layout so the rows keep a + constant height regardless of which rows the figure shows. + """ plot_area_height = _composite_plot_area_height() - available_row_pixels = plot_area_height * _subplot_available_height_fraction(row_count) - baseline_bragg_pixels = _bragg_tick_symbol_height_pixels() if has_bragg_ticks else 0.0 - non_bragg_pixels = max(available_row_pixels - baseline_bragg_pixels, 1.0) + available_row_pixels = plot_area_height * _subplot_available_height_fraction(3) + non_bragg_pixels = max(available_row_pixels - _bragg_tick_symbol_height_pixels(), 1.0) + main_pixels = non_bragg_pixels / (1.0 + DEFAULT_RESIDUAL_HEIGHT_FRACTION) if not has_residual: - return non_bragg_pixels, None + return main_pixels, None - main_pixels = non_bragg_pixels / (1.0 + DEFAULT_RESIDUAL_HEIGHT_FRACTION) residual_pixels = main_pixels * DEFAULT_RESIDUAL_HEIGHT_FRACTION return main_pixels, residual_pixels +def _figure_cm_per_pixel() -> float: + """ + Return the fixed cm-per-pixel scale for fit-figure rows. + + Anchored so the reference three-row layout fills the nominal axis + stack height (axis width times the reference aspect ratio). + """ + reference_stack_cm = _FIGURE_AXIS_WIDTH_CM * _FIGURE_AXIS_HEIGHT_TO_WIDTH + return reference_stack_cm / _composite_plot_area_height() + + def _composite_plot_area_height() -> float: full_height = float(DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT) return max(full_height - COMPOSITE_MARGIN_TOP - COMPOSITE_MARGIN_BOTTOM, 1.0) @@ -270,8 +283,8 @@ def _bragg_row_height_pixels(tick_set_count: int) -> float: return float(tick_set_count) * _bragg_tick_symbol_height_pixels() -def _vertical_sep_cm(stack_height: float) -> float: - return stack_height * COMPOSITE_VERTICAL_SPACING +def _vertical_sep_cm(plot_area_cm: float) -> float: + return plot_area_cm * COMPOSITE_VERTICAL_SPACING def _display_tick_limit(raw_limit: float) -> float: diff --git a/tests/functional/test_wyckoff_tutorial_corpus.py b/tests/functional/test_wyckoff_tutorial_corpus.py new file mode 100644 index 000000000..08306c269 --- /dev/null +++ b/tests/functional/test_wyckoff_tutorial_corpus.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tutorial-corpus regression for Wyckoff-letter detection. + +Each tutorial structure has a known Wyckoff letter for every site, so the +letter is ground truth. This test re-derives the letter from the site's +coordinates and space group and asserts it matches the known one -- broad, +real-world coverage that also guards against regressions whenever the +tutorials change. + +Coverage comes from two sources: + +1. Tutorials that still declare ``wyckoff_letter`` explicitly are parsed + statically (no execution / fitting) and checked directly. +2. Many tutorials were switched to rely on auto-detection (the explicit + letters were removed from the source). Their original declarations are + preserved here as an explicit ``_GROUND_TRUTH`` table so the regression + still exercises those structures -- including the R-3m coupled special + position ``(x, -x, z)`` from ed-6 that motivated the canonical-template + work. +""" + +import ast +import pathlib + +from easydiffraction.crystallography import crystallography as ecr + +_TUTORIALS_DIR = pathlib.Path(__file__).resolve().parents[2] / 'docs' / 'docs' / 'tutorials' + +# Known (space group, coordinate code, fractional coordinates, Wyckoff +# letter) for tutorial sites that no longer declare the letter in source +# (they now exercise auto-detection). Detection must reproduce each letter. +_GROUND_TRUTH = [ + ('F d -3 m', '2', (0.125, 0.125, 0.125), 'a'), + ('F m -3 m', '1', (0.0, 0.0, 0.0), 'a'), + ('F m -3 m', '1', (0.5, 0.5, 0.5), 'b'), + ('I 21 3', '1', (0.0851, 0.0851, 0.0851), 'a'), + ('I 21 3', '1', (0.1377, 0.3054, 0.1195), 'c'), + ('I 21 3', '1', (0.2521, 0.2521, 0.2521), 'a'), + ('I 21 3', '1', (0.3625, 0.3633, 0.1867), 'c'), + ('I 21 3', '1', (0.4612, 0.4612, 0.4612), 'a'), + ('I 21 3', '1', (0.4663, 0.0, 0.25), 'b'), + ('P m -3 m', '1', (0.0, 0.0, 0.0), 'a'), + ('P m -3 m', '1', (0.0, 0.5, 0.5), 'c'), + ('P m -3 m', '1', (0.5, 0.5, 0.5), 'b'), + ('P n m a', 'abc', (0.0, 0.0, 0.0), 'a'), + ('P n m a', 'abc', (0.0654, 0.25, 0.684), 'c'), + ('P n m a', 'abc', (0.0811, 0.0272, 0.8086), 'd'), + ('P n m a', 'abc', (0.091, 0.25, 0.771), 'c'), + ('P n m a', 'abc', (0.094, 0.25, 0.429), 'c'), + ('P n m a', 'abc', (0.164, 0.032, 0.28), 'd'), + ('P n m a', 'abc', (0.1876, 0.25, 0.167), 'c'), + ('P n m a', 'abc', (0.1935, 0.25, 0.5432), 'c'), + ('P n m a', 'abc', (0.279, 0.25, 0.985), 'c'), + ('P n m a', 'abc', (0.448, 0.25, 0.217), 'c'), + ('P n m a', 'abc', (0.9082, 0.25, 0.5954), 'c'), + ('R -3 m', 'h', (0.0, 0.0, 0.197), 'c'), + ('R -3 m', 'h', (0.0, 0.0, 0.5), 'b'), + ('R -3 m', 'h', (0.13, -0.13, 0.08), 'h'), + ('R -3 m', 'h', (0.21, -0.21, 0.06), 'h'), + ('R -3 m', 'h', (0.5, 0.0, 0.0), 'e'), +] + + +def _const(node): + """Return a literal value for a Constant or a negated Constant. + + Returns ``None`` for any other node so non-literal arguments are + ignored. Handles negative numeric literals such as ``-0.21``, which + parse as ``UnaryOp(USub, Constant)`` rather than a bare ``Constant``. + """ + if isinstance(node, ast.Constant): + return node.value + if ( + isinstance(node, ast.UnaryOp) + and isinstance(node.op, ast.USub) + and isinstance(node.operand, ast.Constant) + ): + return -node.operand.value + return None + + +def _string_assignment(tree, attr_name): + """Return the unique str assigned to ``*.`` or ``None``. + + Returns ``None`` when the attribute is assigned zero or several + times, so callers can skip ambiguous (multi-structure) tutorials. + """ + values = [ + _const(node.value) + for node in ast.walk(tree) + if isinstance(node, ast.Assign) and isinstance(_const(node.value), str) + for target in node.targets + if isinstance(target, ast.Attribute) and target.attr == attr_name + ] + if len(values) == 1: + return values[0] + return None + + +def _wyckoff_declarations(tree): + """Yield ``(label, letter, (x, y, z))`` for create() calls with a letter.""" + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + if node.func.attr != 'create': + continue + kwargs = { + kw.arg: _const(kw.value) + for kw in node.keywords + if kw.arg is not None and _const(kw.value) is not None + } + if 'wyckoff_letter' not in kwargs: + continue + coords = ( + float(kwargs.get('fract_x', 0.0)), + float(kwargs.get('fract_y', 0.0)), + float(kwargs.get('fract_z', 0.0)), + ) + yield kwargs.get('label', '?'), kwargs['wyckoff_letter'], coords + + +def _corpus(): + """Collect ``(tutorial, name_hm, code, label, letter, coords)`` rows.""" + rows = [] + for path in sorted(_TUTORIALS_DIR.glob('*.py')): + tree = ast.parse(path.read_text(encoding='utf-8')) + name_hm = _string_assignment(tree, 'name_h_m') + if name_hm is None: + # No structure, or several structures we cannot unambiguously + # associate with create() calls: skip this tutorial. + continue + code = _string_assignment(tree, 'it_coordinate_system_code') + for label, letter, coords in _wyckoff_declarations(tree): + rows.append((path.name, name_hm, code, label, letter, coords)) + return rows + + +def test_tutorial_declared_letters_are_reproduced_by_detection(): + rows = _corpus() + # Guard against a silently empty corpus (e.g. parser drift): some + # tutorials still declare explicit Wyckoff letters. + assert rows, 'no tutorial Wyckoff-letter declarations were found' + + mismatches = [] + for tutorial, name_hm, code, label, letter, coords in rows: + detected = ecr.detect_wyckoff_position(name_hm, code, coords) + if detected is None or detected.letter != letter: + found = None if detected is None else detected.letter + mismatches.append((tutorial, label, name_hm, coords, letter, found)) + + assert not mismatches, mismatches + + +def test_ground_truth_letters_are_reproduced_by_detection(): + # Structures whose explicit letters were removed in favour of + # auto-detection are covered here so the regression still exercises + # them (notably the R-3m coupled special position). + mismatches = [] + for name_hm, code, coords, letter in _GROUND_TRUTH: + detected = ecr.detect_wyckoff_position(name_hm, code, coords) + if detected is None or detected.letter != letter: + found = None if detected is None else detected.letter + mismatches.append((name_hm, code, coords, letter, found)) + + assert not mismatches, mismatches diff --git a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py index d4c22b857..17f84be3d 100644 --- a/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py +++ b/tests/integration/fitting/test_powder-diffraction_constant-wavelength.py @@ -478,6 +478,20 @@ def test_fit_neutron_pd_cwl_hs() -> None: decimal=1, ) + # ed-6 regression: O and H sit on R-3m 'h' = (x,-x,z), so after freeing + # and refining fract_x, fract_y must stay slaved to -fract_x (on-site). + # Operator-form coords_xyz wrongly freed fract_y and let them drift. + assert_almost_equal( + model.atom_sites['O'].fract_y.value, + desired=-model.atom_sites['O'].fract_x.value, + decimal=6, + ) + assert_almost_equal( + model.atom_sites['H'].fract_y.value, + desired=-model.atom_sites['H'].fract_x.value, + decimal=6, + ) + def test_single_fit_neutron_pd_cwl_lbco_with_constraints_from_project(tmp_path) -> None: import easydiffraction as ed diff --git a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py index 6cebe4c33..c2a268c95 100644 --- a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py +++ b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py @@ -101,6 +101,11 @@ def test_update_structure_restores_wyckoff_multiplicity_after_coordinate_wrappin wyckoff_letter='h', adp_iso=0.5, ) + # The calculator reads the per-site multiplicity from the model, so + # run the update flow to populate it via Wyckoff detection (R-3m 'h' + # has multiplicity 18). + structure._update_categories() + assert structure.atom_sites['O'].multiplicity.value == 18 cryspy_model_dict = { 'unit_cell_parameters': [6.86, 6.86, 14.14, np.pi / 2, np.pi / 2, 2 * np.pi / 3], diff --git a/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py b/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py index 377ed7f2f..386b4cb55 100644 --- a/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py +++ b/tests/unit/easydiffraction/crystallography/test_crystallography_wyckoff.py @@ -14,13 +14,15 @@ def test_invalid_name_hm_returns_none(self, monkeypatch): assert result is None monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) - def test_none_coord_code_returns_none(self, monkeypatch): + def test_none_coord_code_resolves_triclinic(self): from easydiffraction.crystallography.crystallography import _get_wyckoff_exprs - monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + # Triclinic groups are keyed ``(IT_number, None)``: P 1 (IT 1) + # resolves through the ``None`` coordinate code to its general + # position (x, y, z) rather than being treated as unset. result = _get_wyckoff_exprs('P 1', None, 'a') - assert result is None - monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + assert result is not None + assert len(result) == 3 def test_valid_returns_three_expressions(self): from easydiffraction.crystallography.crystallography import _get_wyckoff_exprs @@ -54,6 +56,20 @@ def test_valid_applies_constraints(self): result = apply_atom_site_symmetry_constraints(atom, 'P m -3 m', '1', 'a') assert result is not None + def test_coupled_special_position_slaves_dependent_axis(self): + from easydiffraction.crystallography.crystallography import ( + apply_atom_site_symmetry_constraints, + ) + + # R -3 m (IT 166), coord_code='h', Wyckoff 'h' = (x,-x,z): editing + # fract_x slaves fract_y to -fract_x while fract_x/fract_z stay free + # (the ed-6 coupled-position regression). + atom = {'fract_x': 0.3, 'fract_y': 0.0, 'fract_z': 0.5} + result = apply_atom_site_symmetry_constraints(atom, 'R -3 m', 'h', 'h') + assert result['fract_x'] == 0.3 + assert result['fract_y'] == -0.3 + assert result['fract_z'] == 0.5 + class TestAtomSiteSymmetryConstrainedFlags: def test_special_position_all_fixed(self): @@ -74,6 +90,17 @@ def test_general_position_all_free(self): flags = atom_site_symmetry_constrained_flags('P 1', '1', 'a') assert flags == {'fract_x': False, 'fract_y': False, 'fract_z': False} + def test_coupled_special_position_constrains_dependent_axis(self): + from easydiffraction.crystallography.crystallography import ( + atom_site_symmetry_constrained_flags, + ) + + # R -3 m (IT 166), Wyckoff 'h' = (x,-x,z): fract_y is slaved to -x, + # so only fract_y is constrained. Operator-form coords_xyz would + # wrongly mark fract_y free (the canonical-templates regression). + flags = atom_site_symmetry_constrained_flags('R -3 m', 'h', 'h') + assert flags == {'fract_x': False, 'fract_y': True, 'fract_z': False} + def test_invalid_returns_all_false(self, monkeypatch): from easydiffraction.crystallography.crystallography import ( atom_site_symmetry_constrained_flags, @@ -84,3 +111,91 @@ def test_invalid_returns_all_false(self, monkeypatch): flags = atom_site_symmetry_constrained_flags('NOT REAL', None, 'a') assert flags == {'fract_x': False, 'fract_y': False, 'fract_z': False} monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + + +class TestDetectWyckoffPosition: + def test_general_position(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + # A generic point in P m -3 m is the general position 'n' (48). + position = detect_wyckoff_position('P m -3 m', '1', (0.12, 0.23, 0.34)) + assert position.letter == 'n' + assert position.multiplicity == 48 + + def test_special_position(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + position = detect_wyckoff_position('P m -3 m', '1', (0.0, 0.0, 0.0)) + assert position.letter == 'a' + assert position.multiplicity == 1 + + def test_multiplicity_tie_break_prefers_most_special(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + # (1/2,0,0) lies on both 'e' = (x,0,0) (mult 6) and the more + # special 'd' = (1/2,0,0) (mult 3); detection prefers 'd'. + position = detect_wyckoff_position('P m -3 m', '1', (0.5, 0.0, 0.0)) + assert position.letter == 'd' + assert position.multiplicity == 3 + + def test_non_first_orbit_representative(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + # (0,y,0) is on the 'e' orbit via a non-first representative + # ((0,x,0), not the tabulated first rep (x,0,0)). + position = detect_wyckoff_position('P m -3 m', '1', (0.0, 0.3, 0.0)) + assert position.letter == 'e' + + def test_rounded_input_matches_within_tolerance(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + # x = 0.3333 ~ 1/3 is still on 'e' = (x,0,0) at the 1e-3 tolerance. + position = detect_wyckoff_position('P m -3 m', '1', (0.3333, 0.0, 0.0)) + assert position.letter == 'e' + + def test_empty_coord_code_normalises_to_none(self): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + # P 1 is keyed (1, None); an empty coordinate code resolves there. + position = detect_wyckoff_position('P 1', '', (0.1, 0.2, 0.3)) + assert position is not None + assert position.letter == 'a' + + def test_absent_group_returns_none(self, monkeypatch): + from easydiffraction.crystallography.crystallography import detect_wyckoff_position + + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.WARN, raising=True) + assert detect_wyckoff_position('NOT A REAL SG', None, (0.1, 0.2, 0.3)) is None + monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True) + + +class TestWyckoffPositionInfo: + def test_without_coords_has_no_template(self): + from easydiffraction.crystallography.crystallography import wyckoff_position_info + + position = wyckoff_position_info('P m -3 m', '1', 'e') + assert position.letter == 'e' + assert position.multiplicity == 6 + assert position.coord_template is None + + def test_selects_nearest_representative_not_first(self): + from easydiffraction.crystallography.crystallography import ( + snap_to_wyckoff_template, + wyckoff_position_info, + ) + + # 'e' first rep is (x,0,0); for a point near the (0,x,0) member the + # nearest representative must be chosen so the snap keeps fract_y + # free near 0.3 instead of collapsing onto (x,0,0) -> (0,0,0). + position = wyckoff_position_info('P m -3 m', '1', 'e', fract_xyz=(0.0, 0.3, 0.0)) + assert position.coord_template is not None + snapped, _flags = snap_to_wyckoff_template(position.coord_template, (0.0, 0.3, 0.0)) + assert abs(snapped[0]) < 1e-6 + assert abs(snapped[1] - 0.3) < 1e-6 + assert abs(snapped[2]) < 1e-6 + + def test_absent_letter_returns_none(self): + from easydiffraction.crystallography.crystallography import wyckoff_position_info + + # P m -3 m has no Wyckoff letter 'z'. + assert wyckoff_position_info('P m -3 m', '1', 'z') is None diff --git a/tests/unit/easydiffraction/crystallography/test_space_groups.py b/tests/unit/easydiffraction/crystallography/test_space_groups.py index 37eab7235..4d085a8b1 100644 --- a/tests/unit/easydiffraction/crystallography/test_space_groups.py +++ b/tests/unit/easydiffraction/crystallography/test_space_groups.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause """Unit tests for the space-group reference-data loader.""" +import re + from easydiffraction.crystallography.space_groups import SPACE_GROUPS _EXPECTED_RECORD_KEYS = { @@ -24,6 +26,11 @@ # runtime coordinate-code aliases. A deliberate regeneration updates this. _EXPECTED_RECORD_COUNT = 816 +# Canonical coords_xyz forbids operator form (``1/2*x``) and fractional +# coefficients (``5/4x``); integer coefficients (``2x``) and rational +# constants (``x+1/2``) are allowed. +_NONCANONICAL_COORD = re.compile(r'[0-9.]\s*\*\s*[xyz]|[xyz]\s*\*|\d+/\d+\s*[xyz]') + def test_module_import(): import easydiffraction.crystallography.space_groups as MUT @@ -72,3 +79,51 @@ def test_every_record_has_the_expected_schema(): assert isinstance(position['multiplicity'], int) assert isinstance(position['coords_xyz'], list) assert position['coords_xyz'] + + +def test_coords_xyz_are_canonical_distinct_full_orbits(): + """Every Wyckoff coords_xyz is a canonical, distinct, full orbit. + + Regression guard for the canonical-templates fix: no operator-form or + fractional-coefficient spelling, no duplicate template, and a length + equal to the multiplicity (the full centered orbit). + """ + for key, record in SPACE_GROUPS.items(): + for letter, position in record['Wyckoff_positions'].items(): + coords = position['coords_xyz'] + for template in coords: + assert not _NONCANONICAL_COORD.search(template), (key, letter, template) + assert len(set(coords)) == len(coords), (key, letter) + assert len(coords) == position['multiplicity'], (key, letter) + + +def test_representative_template_has_no_self_axis_coupling(): + """Every Wyckoff representative is in canonical parametric form. + + For the representative ``coords_xyz[0]``, no component may couple its + own axis variable with another axis (e.g. an x-slot term ``x+y``): + that is operator-form leakage, the ed-6 regression that the + canonical-templates fix removed. The slot-based constraint logic in + crystallography.py relies on this canonical form, so it is asserted + from the data side to block a silent reintroduction on a future + table regeneration. Non-first orbit members are genuine symmetry + images and may legitimately couple, so only the representative is + checked. + """ + from easydiffraction.crystallography.crystallography import _parse_rotation_matrix + + for key, record in SPACE_GROUPS.items(): + for letter, position in record['Wyckoff_positions'].items(): + representative = position['coords_xyz'][0] + rot, _trans = _parse_rotation_matrix(representative) + for axis_index in range(3): + references_own_axis = rot[axis_index, axis_index] != 0 + couples_other_axis = any( + rot[axis_index, other] != 0 for other in range(3) if other != axis_index + ) + assert not (references_own_axis and couples_other_axis), ( + key, + letter, + representative, + axis_index, + ) diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/test_atom_sites.py b/tests/unit/easydiffraction/datablocks/structure/categories/test_atom_sites.py index 17d0dd5f5..2958fa494 100644 --- a/tests/unit/easydiffraction/datablocks/structure/categories/test_atom_sites.py +++ b/tests/unit/easydiffraction/datablocks/structure/categories/test_atom_sites.py @@ -112,10 +112,16 @@ def test_type_symbol_allowed_values(self): assert 'Fe' in allowed def test_wyckoff_letter_allowed_values(self): - from easydiffraction.datablocks.structure.categories.atom_sites.default import AtomSite + from easydiffraction.datablocks.structure.item.base import Structure - site = AtomSite() - allowed = site._wyckoff_letter_allowed_values + # Allowed letters are derived from the parent structure's space + # group, so the atom must live inside a structure with a + # tabulated space group; a parentless AtomSite has no allowed + # letters. + structure = Structure(name='s') + structure.space_group.name_h_m = 'P m -3 m' + structure.atom_sites.create(label='X', type_symbol='O', adp_iso=0.5) + allowed = structure.atom_sites['X']._wyckoff_letter_allowed_values assert 'a' in allowed def test_uses_iucr_casing_with_legacy_aliases(self): @@ -262,3 +268,152 @@ def test_uani_as_b_converts(self): structure.atom_sites['Si'].adp_type = 'Uani' expected_b = u_val * 8.0 * math.pi**2 assert math.isclose(structure.atom_sites['Si'].adp_iso_as_b, expected_b, rel_tol=1e-10) + + +# ------------------------------------------------------------------ +# Wyckoff letter detection / multiplicity +# ------------------------------------------------------------------ + + +class TestAtomSiteWyckoffDetection: + @staticmethod + def _structure(name_hm='P m -3 m'): + from easydiffraction.datablocks.structure.item.base import Structure + + structure = Structure(name='s') + structure.space_group.name_h_m = name_hm + return structure + + def test_fill_if_empty_on_update(self): + structure = self._structure() + structure.atom_sites.create(label='A', type_symbol='O', adp_iso=0.5) + structure._update_categories() + atom = structure.atom_sites['A'] + assert atom.wyckoff_letter.value == 'a' + assert atom.multiplicity.value == 1 + + def test_redetect_via_property_setter(self): + structure = self._structure() + structure.atom_sites.create(label='A', type_symbol='O', adp_iso=0.5) + structure._update_categories() + structure.atom_sites['A'].fract_x = 0.3 + structure._update_categories() + assert structure.atom_sites['A'].wyckoff_letter.value == 'e' + + def test_redetect_via_descriptor_value(self): + structure = self._structure() + structure.atom_sites.create(label='A', type_symbol='O', adp_iso=0.5) + structure._update_categories() + structure.atom_sites['A'].fract_x.value = 0.3 + structure._update_categories() + assert structure.atom_sites['A'].wyckoff_letter.value == 'e' + + def test_explicit_letter_preserved_on_first_update(self): + # (0.5,0,0) lies on both 'e' = (x,0,0) and the more special + # 'd' = (1/2,0,0); an explicit 'e' must be kept, not detected 'd'. + structure = self._structure() + structure.atom_sites.create( + label='E', + type_symbol='O', + fract_x=0.5, + fract_y=0.0, + fract_z=0.0, + adp_iso=0.5, + wyckoff_letter='e', + ) + structure._update_categories() + atom = structure.atom_sites['E'] + assert atom.wyckoff_letter.value == 'e' + assert atom.multiplicity.value == 6 + + def test_invalid_explicit_letter_raises_on_update(self): + import pytest + + structure = self._structure() + structure.atom_sites.create(label='Z', type_symbol='O', adp_iso=0.5, wyckoff_letter='z') + with pytest.raises(ValueError, match='Invalid Wyckoff letter'): + structure._update_categories() + + def test_same_letter_edit_snaps_off_orbit_coordinate(self): + # 'e' = (x,0,0): a small off-orbit nudge in fract_y (within the + # detection tolerance) keeps the letter 'e' and snaps fract_y back + # to 0 while fract_x stays free. + structure = self._structure() + structure.atom_sites.create( + label='E', + type_symbol='O', + fract_x=0.3, + fract_y=0.0, + fract_z=0.0, + adp_iso=0.5, + ) + structure._update_categories() + assert structure.atom_sites['E'].wyckoff_letter.value == 'e' + structure.atom_sites['E'].fract_x.value = 0.4 + structure.atom_sites['E'].fract_y.value = 0.0005 + structure._update_categories() + atom = structure.atom_sites['E'] + assert atom.wyckoff_letter.value == 'e' + assert abs(atom.fract_x.value - 0.4) < 1e-6 + assert abs(atom.fract_y.value) < 1e-6 + + def test_space_group_change_redetects(self): + structure = self._structure() + structure.atom_sites.create(label='A', type_symbol='O', adp_iso=0.5) + structure._update_categories() + assert structure.atom_sites['A'].multiplicity.value == 1 # Pm-3m 'a' + structure.space_group.name_h_m = 'F m -3 m' + structure._update_categories() + atom = structure.atom_sites['A'] + assert atom.wyckoff_letter.value == 'a' + assert atom.multiplicity.value == 4 # Fm-3m 'a' + + def test_minimizer_path_keeps_letter_fixed(self): + structure = self._structure() + structure.atom_sites.create( + label='E', + type_symbol='O', + fract_x=0.3, + fract_y=0.0, + fract_z=0.0, + adp_iso=0.5, + ) + structure._update_categories() + # A minimizer step varies the free axis; the letter stays fixed + # (no re-detection) and the free coordinate is preserved. + structure.atom_sites['E'].fract_x.value = 0.4 + structure._update_categories(called_by_minimizer=True) + assert structure.atom_sites['E'].wyckoff_letter.value == 'e' + assert abs(structure.atom_sites['E'].fract_x.value - 0.4) < 1e-6 + + def test_untabulated_group_preserves_letter_without_multiplicity(self, monkeypatch): + from easydiffraction.crystallography import crystallography as ecr + + monkeypatch.setattr(ecr, 'space_group_wyckoff_table', lambda *a, **k: None) + structure = self._structure() + structure.atom_sites.create(label='X', type_symbol='O', adp_iso=0.5, wyckoff_letter='a') + structure._update_categories() + atom = structure.atom_sites['X'] + assert atom.wyckoff_letter.value == 'a' + assert atom.multiplicity.value is None + + def test_no_record_contract_clears_multiplicity(self, monkeypatch): + from easydiffraction.crystallography import crystallography as ecr + + monkeypatch.setattr(ecr, 'space_group_wyckoff_table', lambda *a, **k: None) + structure = self._structure() + structure.atom_sites.create(label='X', type_symbol='O', adp_iso=0.5) + structure._update_categories() + assert structure.atom_sites['X'].multiplicity.value is None + assert '_atom_site.site_symmetry_multiplicity' in structure.as_cif + + def test_cif_round_trip_redrives_letter(self): + from easydiffraction.datablocks.structure.item.factory import StructureFactory + + structure = self._structure() + structure.atom_sites.create(label='A', type_symbol='O', adp_iso=0.5) + structure._update_categories() + reloaded = StructureFactory.from_cif_str(structure.as_cif) + reloaded._update_categories() + assert reloaded.atom_sites['A'].wyckoff_letter.value == 'a' + assert reloaded.atom_sites['A'].multiplicity.value == 1 diff --git a/tests/unit/easydiffraction/datablocks/structure/categories/test_space_group_wyckoff.py b/tests/unit/easydiffraction/datablocks/structure/categories/test_space_group_wyckoff.py new file mode 100644 index 000000000..16716343c --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/structure/categories/test_space_group_wyckoff.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the derived space_group_wyckoff category (default + factory).""" + +import pytest + +from easydiffraction.crystallography import crystallography as ecr +from easydiffraction.datablocks.structure.item.base import Structure + + +def _cubic_structure(): + """Return a structure on the tabulated cubic group P m -3 m.""" + structure = Structure(name='s') + structure.space_group.name_h_m = 'P m -3 m' + structure._update_categories() + return structure + + +class TestSpaceGroupWyckoffFactory: + def test_supported_tags(self): + from easydiffraction.datablocks.structure.categories.space_group_wyckoff.factory import ( + SpaceGroupWyckoffFactory, + ) + + assert 'default' in SpaceGroupWyckoffFactory.supported_tags() + + def test_create_default(self): + from easydiffraction.datablocks.structure.categories.space_group_wyckoff.default import ( + SpaceGroupWyckoffCollection, + ) + from easydiffraction.datablocks.structure.categories.space_group_wyckoff.factory import ( + SpaceGroupWyckoffFactory, + ) + + obj = SpaceGroupWyckoffFactory.create('default') + assert isinstance(obj, SpaceGroupWyckoffCollection) + + +class TestDerivedRows: + def test_auto_populates_from_space_group(self): + structure = _cubic_structure() + table = ecr.space_group_wyckoff_table('P m -3 m', '1') + assert len(structure.space_group_wyckoff) == len(table) + for row in structure.space_group_wyckoff: + entry = table[row.letter.value] + assert row.multiplicity.value == entry['multiplicity'] + assert row.site_symmetry.value == str(entry['site_symmetry']) + + def test_uses_id_keys_of_multiplicity_plus_letter(self): + structure = _cubic_structure() + row = structure.space_group_wyckoff['1a'] + assert row.id.value == '1a' + assert row.letter.value == 'a' + assert row.multiplicity.value == 1 + + def test_preserves_site_symmetry_verbatim(self): + # Site-symmetry strings (including any dots) are stored exactly + # as the bundled table provides them, not normalised. + structure = _cubic_structure() + table = ecr.space_group_wyckoff_table('P m -3 m', '1') + for row in structure.space_group_wyckoff: + assert row.site_symmetry.value == str(table[row.letter.value]['site_symmetry']) + + def test_rebuilds_on_space_group_change(self): + structure = _cubic_structure() + assert '1a' in {r.id.value for r in structure.space_group_wyckoff} + structure.space_group.name_h_m = 'F m -3 m' + structure._update_categories() + ids = {r.id.value for r in structure.space_group_wyckoff} + # Fm-3m has no multiplicity-1 'a'; the stale Pm-3m key is gone. + assert '1a' not in ids + assert '4a' in ids + with pytest.raises(KeyError): + _ = structure.space_group_wyckoff['1a'] + + def test_empty_for_absent_group(self, monkeypatch): + structure = _cubic_structure() + assert len(structure.space_group_wyckoff) > 0 + monkeypatch.setattr(ecr, 'space_group_wyckoff_table', lambda *a, **k: None) + structure.space_group_wyckoff._replace_from_space_group() + assert len(structure.space_group_wyckoff) == 0 + + +class TestReadOnly: + def test_rejects_all_public_mutation(self): + structure = _cubic_structure() + wy = structure.space_group_wyckoff + row = wy['1a'] + with pytest.raises(ValueError, match='read-only'): + wy.add(row) + with pytest.raises(ValueError, match='read-only'): + wy.create() + with pytest.raises(ValueError, match='read-only'): + wy.remove('1a') + with pytest.raises(ValueError, match='read-only'): + wy['1a'] = row + with pytest.raises(ValueError, match='read-only'): + del wy['1a'] + + def test_from_cif_ignores_incoming_loop(self): + # A hand-edited _space_group_Wyckoff loop must be discarded; the + # table is derived from the space group, not read from CIF. + from easydiffraction.datablocks.structure.item.factory import StructureFactory + + cif = ( + 'data_x\n' + "_space_group.name_H-M_alt 'P m -3 m'\n" + '_space_group.IT_coordinate_system_code 1\n' + 'loop_\n' + '_space_group_Wyckoff.id\n' + '_space_group_Wyckoff.letter\n' + '_space_group_Wyckoff.multiplicity\n' + '_space_group_Wyckoff.site_symmetry\n' + '_space_group_Wyckoff.coords_xyz\n' + ' BOGUS bogus 999 zzz (9,9,9)\n' + ) + structure = StructureFactory.from_cif_str(cif) + assert all(r.id.value != 'BOGUS' for r in structure.space_group_wyckoff) + + +class TestSerialization: + def test_omitted_from_project_cif(self): + structure = _cubic_structure() + assert '_space_group_Wyckoff' not in structure.as_cif + + def test_appears_in_report_output(self): + from easydiffraction.io.cif import iucr_writer + + structure = _cubic_structure() + lines: list[str] = [] + iucr_writer._write_space_group_wyckoff_section(lines, structure) + body = '\n'.join(lines) + assert '_space_group_Wyckoff.id' in body + assert '_space_group_Wyckoff.coords_xyz' in body + # Representative coordinate only (compact), never the full orbit. + assert max(len(line) for line in lines) < 80 diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 51245c04c..53200f98a 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -885,7 +885,7 @@ def fake_show_figure(self, fig): x=np.array([1.0, 2.0, 3.0]), y_meas=np.array([10.0, 12.0, 11.0]), y_calc=np.array([9.0, 11.0, 10.5]), - y_resid=None, + y_resid=np.array([1.0, 1.0, 0.5]), bragg_tick_sets=( BraggTickSet( phase_id='phase-a', @@ -905,9 +905,74 @@ def fake_show_figure(self, fig): ), ) + # A full main + Bragg + residual composite renders at exactly the + # explicit height; reduced layouts derive a smaller height instead. assert captured['fig'].layout.height == 800 +def test_plot_powder_meas_vs_calc_keeps_top_and_bottom_rows_fixed(monkeypatch): + """Top and residual rows keep a fixed pixel height.""" + import easydiffraction.display.plotters.plotly as pp + + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec + + captured = {} + + def fake_show_figure(self, fig): + captured.setdefault('figures', []).append(fig) + + monkeypatch.setattr(pp.PlotlyPlotter, '_show_figure', fake_show_figure) + + bragg_tick_sets = ( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + ) + + def plot_spec(*, with_bragg: bool, with_residual: bool) -> PowderMeasVsCalcSpec: + return PowderMeasVsCalcSpec( + x=np.array([1.0, 2.0, 3.0]), + y_meas=np.array([10.0, 12.0, 11.0]), + y_calc=np.array([9.0, 11.0, 10.5]), + y_resid=np.array([1.0, 1.0, 0.5]) if with_residual else None, + bragg_tick_sets=bragg_tick_sets if with_bragg else (), + axes_labels=['2θ (deg)', 'Intensity (arb. units)'], + title='Powder', + residual_height_fraction=0.25, + bragg_peaks_height_fraction=0.10, + height=None, + ) + + plotter = pp.PlotlyPlotter() + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(with_bragg=True, with_residual=True)) + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(with_bragg=True, with_residual=False)) + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(with_bragg=False, with_residual=True)) + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(with_bragg=False, with_residual=False)) + + full, main_bragg, main_resid, main_only = captured['figures'] + + def row_pixels(fig, axis_name: str) -> float: + axis = getattr(fig.layout, axis_name) + plot_area_height = fig.layout.height - fig.layout.margin.t - fig.layout.margin.b + return plot_area_height * (axis.domain[1] - axis.domain[0]) + + # The top (main) row keeps the same pixel height in every layout. + assert row_pixels(main_bragg, 'yaxis') == pytest.approx(row_pixels(full, 'yaxis')) + assert row_pixels(main_resid, 'yaxis') == pytest.approx(row_pixels(full, 'yaxis')) + assert row_pixels(main_only, 'yaxis') == pytest.approx(row_pixels(full, 'yaxis')) + # The residual row keeps its height whether or not Bragg shows. + assert row_pixels(main_resid, 'yaxis2') == pytest.approx(row_pixels(full, 'yaxis3')) + # Hiding rows shrinks the figure instead of stretching the top row. + assert main_only.layout.height < main_resid.layout.height < full.layout.height + + def test_plot_powder_meas_vs_calc_skips_bragg_row_when_no_ticks(monkeypatch): import easydiffraction.display.plotters.plotly as pp diff --git a/tests/unit/easydiffraction/report/test_fit_plot.py b/tests/unit/easydiffraction/report/test_fit_plot.py index 0a231f1a8..898d825bf 100644 --- a/tests/unit/easydiffraction/report/test_fit_plot.py +++ b/tests/unit/easydiffraction/report/test_fit_plot.py @@ -72,3 +72,43 @@ def test_fit_plot_axis_styles_expose_shared_diagonal_color(): styles = fit_plot_axis_styles() assert styles['diag_rgb'] == '190,199,208' + + +def test_fit_plot_geometry_keeps_panel_heights_fixed_across_rows(): + from easydiffraction.report.fit_plot import _FIGURE_AXIS_HEIGHT_TO_WIDTH + from easydiffraction.report.fit_plot import _FIGURE_AXIS_WIDTH_CM + from easydiffraction.report.fit_plot import fit_plot_geometry + + def fit_data(*, phases: int, residual: bool) -> dict: + data = {'bragg_tick_sets': ['phase'] * phases} + data['series'] = {'diff': [0.0]} if residual else {} + return data + + def stack_height_cm(geometry: dict, row_count: int) -> float: + return ( + geometry['main_height_cm'] + + geometry['bragg_height_cm'] + + geometry['residual_height_cm'] + + (row_count - 1) * geometry['vertical_sep_cm'] + ) + + full = fit_plot_geometry(fit_data(phases=1, residual=True)) + main_bragg = fit_plot_geometry(fit_data(phases=1, residual=False)) + main_resid = fit_plot_geometry(fit_data(phases=0, residual=True)) + main_only = fit_plot_geometry(fit_data(phases=0, residual=False)) + two_phase = fit_plot_geometry(fit_data(phases=2, residual=True)) + + # The main panel keeps the same cm height in every layout. + assert main_bragg['main_height_cm'] == pytest.approx(full['main_height_cm']) + assert main_resid['main_height_cm'] == pytest.approx(full['main_height_cm']) + assert main_only['main_height_cm'] == pytest.approx(full['main_height_cm']) + # The residual panel keeps its height with or without Bragg ticks. + assert main_resid['residual_height_cm'] == pytest.approx(full['residual_height_cm']) + # The Bragg panel grows linearly with the phase count. + assert two_phase['bragg_height_cm'] == pytest.approx(2 * full['bragg_height_cm']) + # The reference three-row figure keeps its nominal height; reduced + # layouts are shorter and extra phases make it taller. + reference_cm = _FIGURE_AXIS_WIDTH_CM * _FIGURE_AXIS_HEIGHT_TO_WIDTH + assert stack_height_cm(full, 3) == pytest.approx(reference_cm) + assert stack_height_cm(main_only, 1) < reference_cm + assert stack_height_cm(two_phase, 3) > reference_cm diff --git a/tools/check_packaged_db.py b/tools/check_packaged_db.py index d0bac73bd..efb844ecf 100644 --- a/tools/check_packaged_db.py +++ b/tools/check_packaged_db.py @@ -6,8 +6,10 @@ of the project's full dependency tree: it opens the wheel, reads ``space_groups.json.gz`` straight from it, and asserts the data is shipped as package data, that the obsolete ``space_groups.pkl.gz`` is gone, and that the -archive covers all 230 IT groups plus the cryspy coordinate-code alias surface. -Exits non-zero on any problem so a packaging regression fails the caller. +archive covers all 230 IT groups plus the cryspy coordinate-code alias +surface, and that no Wyckoff ``coords_xyz`` template is in cctbx operator form +(canonical ITA form only). Exits non-zero on any problem so a packaging +regression fails the caller. Usage: ``python tools/check_packaged_db.py [path/to/wheel]`` (defaults to the newest wheel in ``dist/``). @@ -17,6 +19,7 @@ import gzip import json +import re import sys import zipfile from pathlib import Path @@ -26,6 +29,9 @@ _REQUIRED_KEYS = [(14, '-b1'), (3, '-a1'), (1, None)] # Accepted seed record count (see the space-group-database ADR provenance). _EXPECTED_RECORD_COUNT = 816 +# Wyckoff coords_xyz must be canonical ITA form, never cctbx operator form +# (e.g. ``1/2*x-1/2*y``), which breaks symmetry-constraint detection. +_OPERATOR_FORM = re.compile(r'[0-9.]\s*\*\s*[xyz]|[xyz]\s*\*') def _wheel_path(argv: list[str]) -> Path: @@ -59,6 +65,19 @@ def main(argv: list[str]) -> None: if len(keys) != _EXPECTED_RECORD_COUNT: sys.exit(f'packaged database has {len(keys)} unique keys, expected {_EXPECTED_RECORD_COUNT}') + operator_form = [ + (record['IT_number'], record['IT_coordinate_system_code'], letter, template) + for record in records + for letter, position in record['Wyckoff_positions'].items() + for template in position['coords_xyz'] + if _OPERATOR_FORM.search(template) + ] + if operator_form: + sys.exit( + f'packaged database has {len(operator_form)} operator-form coords_xyz ' + f'(canonical ITA form required); first: {operator_form[0]}' + ) + print( f'packaged DB OK in {wheel.name}: ' f'{len({key[0] for key in keys})} IT groups, {len(records)} settings' From 10a167cadcbdcf63f726d50774e46b4aeaa1ce3c Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 3 Jun 2026 20:30:18 +0200 Subject: [PATCH 11/12] Update CIF_URL to new GitHub release link (#190) We make nightly releases of reduced files on github now, so no need for this R2 bucket :) https://github.com/scipp/ess/releases/tag/reduced_data_nightly --- tests/integration/scipp-analysis/dream/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/scipp-analysis/dream/conftest.py b/tests/integration/scipp-analysis/dream/conftest.py index b16da0a14..a0698a4af 100644 --- a/tests/integration/scipp-analysis/dream/conftest.py +++ b/tests/integration/scipp-analysis/dream/conftest.py @@ -14,7 +14,7 @@ from pooch import retrieve # Remote CIF file URL (regenerated nightly by scipp reduction pipeline) -CIF_URL = 'https://pub-6c25ef91903d4301a3338bd53b370098.r2.dev/dream_reduced.cif' +CIF_URL = 'https://github.com/scipp/ess/releases/download/reduced_data_nightly/dream_reduced.cif' # Expected datablock name in the CIF file DATABLOCK_NAME = 'reduced_tof' From 4118ef6704aecdc6ff0b672d70cb87b06a5b8479 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 4 Jun 2026 16:20:47 +0200 Subject: [PATCH 12/12] Save tutorial projects consistently and verify their fit results (#191) * Standardize tutorial project save paths * Seed and standardize save paths for Bayesian tutorials * Regenerate tutorial notebooks * Add tutorial-output regression test against fit baseline * Apply pixi run fix auto-fixes * Assert persisted result_kind in tutorial-output test * Run tutorial-output checks in CI * Clean saved tutorial projects before tutorial tests * Skip numeric baseline for platform-sensitive tutorials * Set non-zero defaults for TOF profiles * Trim platform-sensitive skip message * Update tutorial Si structure and doc URLs * Add pattern-display-unification implementation plan * Add top margin above structure scene rectangle * Match single-panel pattern height and x-range to composite * Always render available pattern content; drop include * Amend display-ux ADR for always-on pattern view * Apply formatter to pattern docstring and docs * Drop include= from tutorials for unified pattern view * Reach Phase 1 review gate * Add Raises section to pattern docstring * Set single-panel height via layout construction * Update unit tests for unified pattern view * Remove py3Dmol, relax pillow constraint * Update integration plotting tests for dropped include * Mark Phase 2 verification complete * Record pattern unification as a dedicated ADR * Show pretty units in parameter repr * Omit space_group_Wyckoff loop from IUCr reports * Hide space_group_Wyckoff via _skip_cif_serialization hook * Make Wyckoff skip-serialization hook a staticmethod * Update Wyckoff plan for serialization-hook refactor * Apply latest templates * Assert crysfml engine is loaded in switch-calculator test * Reformat builtins as multiline list * Increase page max-width to 118em * Point report API page at easydiffraction.report package * Track host theme in 3D-view loading box, relabel to plot * Size fit-series scatter plot to the pattern top panel * Increase page and content max-widths * Hide space_group_Wyckoff from parameter tables * Render integer descriptors in parameter table * Pre-bin posterior distribution histogram, not raw samples * Move notebook action buttons to top left above title * Drop duplicate pair scatter hover layer, halve sample cap * Apply ruff format to distribution histogram test * Include integer descriptors in remaining parameter tables * Add regression tests for parameter table rendering * Name tutorial projects to match their save directories * Render pandas tables as inline-styled HTML * Remove dead table theme-sync leftovers * Neutralize MkDocs Material table CSS rules * Rename tutorial projects to match save directories * Temporarily mark all structures as nuclear-only * Match project outputs to save directories * Validate refined tutorial parameters from model and experiment CIFs * Update tutorial projects and remove structure display --- .copier-answers.yml | 2 +- .github/workflows/coverage.yml | 26 +- .github/workflows/tutorial-tests.yml | 4 +- .prettierignore | 3 + .../crysview-structure-visualization.md | 23 +- docs/dev/adrs/accepted/display-ux.md | 86 +--- .../accepted/pattern-display-unification.md | 92 ++++ .../adrs/accepted/wyckoff-letter-detection.md | 30 +- docs/dev/adrs/index.md | 1 + docs/dev/plans/pattern-display-unification.md | 151 +++++++ docs/dev/plans/wyckoff-letter-detection.md | 45 +- docs/docs/api-reference/report.md | 2 +- docs/docs/assets/javascripts/extra.js | 9 - docs/docs/assets/stylesheets/extra.css | 33 +- docs/docs/quick-reference/index.md | 4 +- docs/docs/tutorials/ed-1.ipynb | 22 +- docs/docs/tutorials/ed-1.py | 10 +- docs/docs/tutorials/ed-10.ipynb | 20 +- docs/docs/tutorials/ed-10.py | 8 +- docs/docs/tutorials/ed-11.ipynb | 22 +- docs/docs/tutorials/ed-11.py | 10 +- docs/docs/tutorials/ed-12.ipynb | 20 +- docs/docs/tutorials/ed-12.py | 8 +- docs/docs/tutorials/ed-13.ipynb | 90 ++-- docs/docs/tutorials/ed-13.py | 90 ++-- docs/docs/tutorials/ed-14.ipynb | 4 +- docs/docs/tutorials/ed-14.py | 4 +- docs/docs/tutorials/ed-15.ipynb | 22 +- docs/docs/tutorials/ed-15.py | 10 +- docs/docs/tutorials/ed-16.ipynb | 20 +- docs/docs/tutorials/ed-16.py | 8 +- docs/docs/tutorials/ed-17.ipynb | 4 +- docs/docs/tutorials/ed-17.py | 4 +- docs/docs/tutorials/ed-18.ipynb | 18 + docs/docs/tutorials/ed-18.py | 6 + docs/docs/tutorials/ed-2.ipynb | 4 +- docs/docs/tutorials/ed-2.py | 4 +- docs/docs/tutorials/ed-20.ipynb | 4 +- docs/docs/tutorials/ed-20.py | 4 +- docs/docs/tutorials/ed-21.ipynb | 7 +- docs/docs/tutorials/ed-21.py | 5 +- docs/docs/tutorials/ed-22.ipynb | 7 +- docs/docs/tutorials/ed-22.py | 5 +- docs/docs/tutorials/ed-23.ipynb | 18 + docs/docs/tutorials/ed-23.py | 6 + docs/docs/tutorials/ed-24.ipynb | 18 + docs/docs/tutorials/ed-24.py | 6 + docs/docs/tutorials/ed-25.ipynb | 7 +- docs/docs/tutorials/ed-25.py | 5 +- docs/docs/tutorials/ed-26.ipynb | 19 + docs/docs/tutorials/ed-26.py | 7 + docs/docs/tutorials/ed-3.ipynb | 194 ++++---- docs/docs/tutorials/ed-3.py | 13 +- docs/docs/tutorials/ed-4.ipynb | 20 +- docs/docs/tutorials/ed-4.py | 8 +- docs/docs/tutorials/ed-5.ipynb | 4 +- docs/docs/tutorials/ed-5.py | 4 +- docs/docs/tutorials/ed-6.ipynb | 20 +- docs/docs/tutorials/ed-6.py | 8 +- docs/docs/tutorials/ed-7.ipynb | 20 +- docs/docs/tutorials/ed-7.py | 8 +- docs/docs/tutorials/ed-8.ipynb | 20 +- docs/docs/tutorials/ed-8.py | 8 +- docs/docs/tutorials/ed-9.ipynb | 24 +- docs/docs/tutorials/ed-9.py | 12 +- docs/mkdocs.yml | 13 +- docs/overrides/main.html | 32 +- pixi.lock | 12 +- pixi.toml | 26 +- pyproject.toml | 10 +- src/easydiffraction/analysis/analysis.py | 107 ++--- .../analysis/calculators/cryspy.py | 10 + src/easydiffraction/core/variable.py | 10 +- .../experiment/categories/peak/tof_mixins.py | 29 +- .../categories/space_group_wyckoff/default.py | 20 + .../datablocks/structure/item/base.py | 10 - src/easydiffraction/display/plotters/base.py | 3 + .../display/plotters/plotly.py | 24 +- src/easydiffraction/display/plotting.py | 76 ++-- .../structure/templates/structure.html.j2 | 4 +- src/easydiffraction/display/tablers/pandas.py | 419 ++++++------------ src/easydiffraction/display/theme.py | 2 - src/easydiffraction/io/cif/iucr_writer.py | 49 -- src/easydiffraction/project/display.py | 174 ++------ tests/integration/fitting/test_plotting.py | 17 +- .../fitting/test_switch-calculator.py | 5 + tests/tutorials/analysis_cif_reader.py | 265 +++++++++++ tests/tutorials/baseline.json | 266 +++++++++++ tests/tutorials/conftest.py | 15 + tests/tutorials/generate_baseline.py | 127 ++++++ tests/tutorials/test_tutorial_outputs.py | 107 +++++ .../analysis/test_analysis_access_params.py | 115 +++++ .../easydiffraction/core/test_parameters.py | 42 ++ .../categories/test_space_group_wyckoff.py | 20 +- .../display/plotters/test_plotly.py | 50 +++ .../display/tablers/test_pandas.py | 133 ++++-- .../easydiffraction/display/test_plotting.py | 77 +++- .../io/cif/test_iucr_writer.py | 22 + .../easydiffraction/project/test_display.py | 80 ++-- .../report/test_data_context.py | 15 + tools/clean_tutorial_projects.py | 42 ++ 101 files changed, 2661 insertions(+), 1167 deletions(-) create mode 100644 docs/dev/adrs/accepted/pattern-display-unification.md create mode 100644 docs/dev/plans/pattern-display-unification.md create mode 100644 tests/tutorials/analysis_cif_reader.py create mode 100644 tests/tutorials/baseline.json create mode 100644 tests/tutorials/conftest.py create mode 100644 tests/tutorials/generate_baseline.py create mode 100644 tests/tutorials/test_tutorial_outputs.py create mode 100644 tools/clean_tutorial_projects.py diff --git a/.copier-answers.yml b/.copier-answers.yml index 009e6acc4..32c8c49a5 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,6 +1,6 @@ # WARNING: Do not edit this file manually. # Any changes will be overwritten by Copier. -_commit: v0.11.4 +_commit: v0.12.0 _src_path: gh:easyscience/templates app_docs_url: https://easyscience.github.io/diffraction-app app_doi: 10.5281/zenodo.18163581 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index d96e5b87d..5208a3333 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -63,32 +63,8 @@ jobs: files: ./coverage-unit.xml token: ${{ secrets.CODECOV_TOKEN }} - # Job 2: Run integration tests with coverage and upload to Codecov - integration-tests-coverage: - runs-on: ubuntu-latest - - steps: - - name: Check-out repository - uses: actions/checkout@v6 - - - name: Set up pixi - uses: ./.github/actions/setup-pixi - - - name: Run integration tests with coverage - run: - pixi run integration-tests-coverage --cov-report=xml:coverage-integration.xml - - - name: Upload integration tests coverage to Codecov - if: ${{ !cancelled() }} - uses: ./.github/actions/upload-codecov - with: - name: integration-tests-job - flags: integration - files: ./coverage-integration.xml - token: ${{ secrets.CODECOV_TOKEN }} - # Job 4: Build and publish dashboard (reusable workflow) run-reusable-workflows: - needs: [docstring-coverage, unit-tests-coverage, integration-tests-coverage] # depend on the previous jobs + needs: [docstring-coverage, unit-tests-coverage] # depend on the previous jobs uses: ./.github/workflows/dashboard.yml secrets: inherit diff --git a/.github/workflows/tutorial-tests.yml b/.github/workflows/tutorial-tests.yml index f924a31d3..4e93a6383 100644 --- a/.github/workflows/tutorial-tests.yml +++ b/.github/workflows/tutorial-tests.yml @@ -47,7 +47,7 @@ jobs: - name: Test tutorials as python scripts shell: bash - run: pixi run script-tests + run: pixi run script-tests-checked - name: Prepare notebooks shell: bash @@ -55,7 +55,7 @@ jobs: - name: Test tutorials as notebooks shell: bash - run: pixi run notebook-tests + run: pixi run notebook-tests-checked # Job 2: Build and publish dashboard (reusable workflow) run-reusable-workflows: diff --git a/.prettierignore b/.prettierignore index b894e10fb..32b5970dc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,6 +31,9 @@ src/easydiffraction/report/templates/html/vendor/ src/easydiffraction/report/templates/tex/styles/ src/easydiffraction/utils/_vendored/jupyter_dark_detect/ +# Tox +.tox + # Misc .benchmarks .cache diff --git a/docs/dev/adrs/accepted/crysview-structure-visualization.md b/docs/dev/adrs/accepted/crysview-structure-visualization.md index b5fc2281a..0852726f3 100644 --- a/docs/dev/adrs/accepted/crysview-structure-visualization.md +++ b/docs/dev/adrs/accepted/crysview-structure-visualization.md @@ -158,8 +158,7 @@ representation); like the HTML report it can also write a standalone HTML file to a path. The exact return and save signature is left to the implementation plan. -Content selection mirrors `pattern(include=...)` rather than inventing a -new vocabulary: +Content selection uses an `include=` argument: ```python project.display.structure(struct_name='lbco') @@ -181,13 +180,11 @@ toggles the same features after the initial view is drawn, so `include` sets the starting state and the modebar refines it. A companion `project.display.show_structure_options(struct_name=...)` -mirrors the existing `show_pattern_options(expt_name=...)`: it lists -each `include=` option with whether the active engine and the current -structure state support it, and the reason when they do not — for -example `moments` is unavailable until the structure model carries +lists each `include=` option with whether the active engine and the +current structure state support it, and the reason when they do not — +for example `moments` is unavailable until the structure model carries moment fields, and the `ascii` engine reports the features only the 3D -engines draw. This gives the structure view the same per-option -discoverability the pattern view already offers. +engines draw. This gives the structure view per-option discoverability. The view also has a spatial extent: which symmetry-equivalent atoms the scene contains. The scene builder takes the unique (asymmetric-unit) @@ -613,11 +610,11 @@ a per-call request behave predictably: ## Consequences - `project.display` gains a spatial view (`structure()`) that - complements the 1D `pattern()` view and reuses the `include=` - vocabulary. -- `project.display` also gains `show_structure_options()`, parallel to - `show_pattern_options()`, so the supported content for a given - structure and engine is discoverable with reasons. + complements the 1D `pattern()` view with an `include=` feature + selector. +- `project.display` also gains `show_structure_options()`, so the + supported content for a given structure and engine is discoverable + with reasons. - Keeping crystallography in the scene builder and out of renderers lets several front-ends (Three.js now, Qt Quick 3D later) share one model. - A switchable `rendering_structure` category diff --git a/docs/dev/adrs/accepted/display-ux.md b/docs/dev/adrs/accepted/display-ux.md index 40ee2c0d1..edd55f3b3 100644 --- a/docs/dev/adrs/accepted/display-ux.md +++ b/docs/dev/adrs/accepted/display-ux.md @@ -82,8 +82,6 @@ project.display.fit.series(param, versus='diffrn.ambient_temperature') project.display.posterior.pairs() project.display.posterior.distribution(param) project.display.posterior.predictive(expt_name='hrpt') - -project.display.show_pattern_options(expt_name='hrpt') ``` `project.analysis.display` is removed from the primary public API. Its @@ -116,77 +114,14 @@ project.display.pattern(expt_name='hrpt') project.display.pattern(expt_name='hrpt', x_min=40, x_max=55) ``` -By default, `pattern()` uses `include='auto'` and displays as much -useful information as the project state supports: - -- measured data if present -- calculated data if linked structure state and calculated intensities - are available -- background if powder Bragg measured and calculated data plus defined - background points are available -- Bragg ticks if powder Bragg measured and calculated data plus - reflection rows are available -- residual if both measured and calculated data are available and the - experiment type supports a residual panel -- excluded regions if available on the experiment -- uncertainty bands where posterior predictive data exists and the chart - engine supports them - -Specific subsets are selected with `include`: - -```python -project.display.pattern(expt_name='hrpt', include='auto') -project.display.pattern(expt_name='hrpt', include='measured') -project.display.pattern(expt_name='hrpt', include='calculated') -project.display.pattern( - expt_name='hrpt', - include=('measured', 'calculated', 'background', 'residual', 'bragg'), -) -``` - -`include` was chosen over alternatives: - -| Name | Reason not selected | -| ------------- | ----------------------------------------------- | -| `layers` | Sounds graphical rather than user intent. | -| `components` | Precise, but longer. | -| `content` | Too broad. | -| `view` | Better for presets than arbitrary combinations. | -| `series` | Does not fit residual rows or Bragg ticks well. | -| boolean flags | Explicit, but scales poorly. | - -Add discovery for supported pattern content: - -```python -project.display.show_pattern_options(expt_name='hrpt') -``` - -The table shows option name, description, availability for the -experiment, whether `include='auto'` includes it, and the reason an -option is unavailable. - -Pattern option names: - -- `auto` -- `measured` -- `calculated` -- `background` -- `residual` -- `bragg` -- `excluded` -- `uncertainty` - -`uncertainty` is available where posterior predictive data exists for a -supported experiment and the active chart engine can render bands. It is -unavailable, with a clear reason, when no posterior predictive data is -present. - -Explicit combinations are validated against the same project state used -by `include='auto'`. `background`, `bragg`, and `residual` require both -measured and calculated data in the same view. `excluded` requires -measured, calculated, or uncertainty content in the same view, and -excluded-region overlays currently require the experiment's default -x-axis. +`pattern()` renders every kind of data the project state supports — +measured, calculated, residual, Bragg ticks, background, excluded +regions, and posterior predictive uncertainty, each shown when +available. It takes no view-selection argument. The content rules, the +removed `include` / `show_pattern_options` design, and the shared +single- and multi-panel figure sizing are recorded in the +[Unified Pattern View](pattern-display-unification.md) ADR, which +supersedes the `include`-based pattern design once described here. ## Deterministic And Bayesian Consistency @@ -230,8 +165,9 @@ users should not need to decide the output type before asking for information. Some outputs may render as a chart or a table depending on backend and state. -Separate `measured()` and `calculated()` methods were rejected because -they duplicate `pattern(..., include=...)`. +Separate `measured()` and `calculated()` methods are unnecessary: +`pattern()` shows every available kind of data directly, so there is no +subset for them to select. ## Consequences diff --git a/docs/dev/adrs/accepted/pattern-display-unification.md b/docs/dev/adrs/accepted/pattern-display-unification.md new file mode 100644 index 000000000..e4f057a00 --- /dev/null +++ b/docs/dev/adrs/accepted/pattern-display-unification.md @@ -0,0 +1,92 @@ +# ADR: Unified Pattern View + +## Status + +Accepted and implemented. + +## Date + +2026-06-04 + +## Context + +`project.display.pattern()` was originally specified by the +[Display UX Facade](display-ux.md) ADR with an `include=` argument that +assembled a view from named layers (`measured`, `calculated`, +`background`, `residual`, `bragg`, `excluded`, `uncertainty`), plus a +`show_pattern_options()` discovery table. `include='auto'` already chose +the most informative combination from project state. + +In practice this surfaced several problems: + +- The single-panel path (`plot_meas`/`plot_calc` → `plot_powder`) and + the composite path (`build_powder_meas_vs_calc_figure`) computed + figure height and x-range independently, so they drifted. A + measured-only view rendered at the full three-panel height in the lazy + docs runtime — the + [Plotting & Docs Performance](plotting-docs-performance.md) skeleton + fell back to `DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT` — and gained + stray left/right autoscale padding the composite did not have. +- `'excluded'` was an opt-in overlay, but excluded regions are a + property of the experiment, not a viewing choice; `include='measured'` + and `include=('measured', 'excluded')` produced different plots of the + same data, which read as redundant. +- For a scientist audience, choosing layers is friction. The project + state already determines what is meaningful to show, which is exactly + what `include='auto'` computed. + +## Decision + +`pattern(expt_name, x_min=None, x_max=None, *, x=None)` always renders +every kind of data the project state supports — the former +`include='auto'` behaviour is now the only behaviour. The `include` +parameter, the `show_pattern_options()` method, and the option-status +discovery table are removed. Strict-subset views (for example +measured-only once a calculation exists) are intentionally no longer +offered; zooming (`x_min`/`x_max`) and the x-axis variable (`x`) remain. + +Excluded regions are always shaded when defined on the experiment, +skipped only when a custom `x` axis variable is selected, because the +overlay cannot be mapped onto an arbitrary axis. + +Single-panel and composite charts share one figure-sizing and x-range +core. `plot_powder` builds its layout with the same +`_single_main_panel_height_pixels(...)` height and tight +`_composite_x_range(...)` as the composite main row, and `_get_layout` +already applies the composite margins. A one-row chart is therefore the +top row of the multi-row chart pixel for pixel, by construction, so the +two paths cannot diverge again. + +This supersedes the `include`-based pattern design in the +[Display UX Facade](display-ux.md) ADR. The remainder of that ADR — the +facade grouping, renderer categories, and naming rules — still stands. + +## Consequences + +- `pattern(expt_name=...)` is the whole pattern API surface; tutorials, + docs, and tests no longer pass `include=`. +- The project is in beta, so this replaces the previous API with no + compatibility shim; tutorials and tests are updated to the current + API. +- Sizing and range differences between one- and three-panel views are + prevented structurally, not patched per call. +- `structure(include=...)` and `show_structure_options()` are + unaffected: choosing which 3D features to draw remains a genuine + viewing choice (see + [Crystal Structure 3D Visualization](crysview-structure-visualization.md)). + +## Alternatives Considered + +Keeping `include` as the view-selection vocabulary (the original +design). `include` had been chosen over `layers`, `components`, +`content`, `view`, `series`, and boolean flags because it read as user +intent and fit residual rows and Bragg ticks. It is removed now because +the only combination users reached for in practice was the automatic +"show everything available" view; the subset combinations added API +surface and a discovery table without a matching workflow, and the +parallel single-panel rendering path was the source of the sizing and +range divergence. + +Keeping `'excluded'` as an opt-in overlay, or as a redundant no-op +token, was rejected: excluded regions belong to the experiment, so +shading them is automatic whenever they are present. diff --git a/docs/dev/adrs/accepted/wyckoff-letter-detection.md b/docs/dev/adrs/accepted/wyckoff-letter-detection.md index 47df620bc..c8b4340bb 100644 --- a/docs/dev/adrs/accepted/wyckoff-letter-detection.md +++ b/docs/dev/adrs/accepted/wyckoff-letter-detection.md @@ -2,6 +2,14 @@ **Status:** Accepted **Date:** 2026-06-01 +> **Amendment (2026-06-04):** the derived `space_group_Wyckoff` loop is +> now **code-only** — it is excluded from IUCr/report output as well as +> from project CIF. Wording below that calls the loop "report-facing" or +> says the report writer "emits" it (the §"`space_group_Wyckoff` loop" +> decision and the matching Consequences bullet) is superseded: the +> table is reachable in code via `structure.space_group_wyckoff` but is +> never serialized. + ## Group Structure model. @@ -447,13 +455,17 @@ which fall out naturally below. already emits the resolved `Wyckoff_symbol` for every atom and now also emits `_atom_site.site_symmetry_multiplicity`. - **`space_group_Wyckoff` loop.** The derived category is model-owned - and report-facing, but it is not persisted in project CIF. `Structure` - explicitly excludes it from project-save serialization via - `_serializable_categories()`, overriding `CategoryOwner`'s default of - serializing all owned categories. The IUCr/report writer emits the - `_space_group_Wyckoff.*` loop from the derived category because that - loop is useful report output even though it is redundant persisted - state. + and code-facing only; it is not serialized. The collection suppresses + its own output through `_skip_cif_serialization()` returning `True`, + the same collection-owned hook `atom_site_aniso` uses (conditionally); + every serialization path that consults it honours the suppression — + `category_collection_to_cif` (project CIF) and the report data context + — so the `_space_group_Wyckoff.*` loop never appears in project CIF or + HTML/TeX reports, and the hand-rolled IUCr writer does not emit it + either. The decision lives on the category, so `Structure` needs no + `_serializable_categories()` override. The table is redundant derived + state, reachable in code via `structure.space_group_wyckoff` (amended + 2026-06-04). - **Derived values on read.** Multiplicity is recomputed from the letter, so any incoming `_atom_site.site_symmetry_multiplicity` is ignored rather than trusted; the `space_group_Wyckoff` category is @@ -677,8 +689,8 @@ may miss: structure's space group (each entry's letter / multiplicity / site_symmetry / coords match `SPACE_GROUPS`), rebuilds when the space group changes, refuses all public mutation paths, is empty for an - absent group, is omitted from project CIF, and is emitted in - IUCr/report output; + absent group, and is omitted from both project CIF and IUCr/report + output (code-only, reachable via `structure.space_group_wyckoff`); - CIF round-trip stability — a written letter reloads verbatim, an omitted one re-derives to the same value, and an unsupported-group row keeps `None` multiplicity whether its letter is empty, explicitly diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index eea0d1af1..4156a5eb0 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -56,4 +56,5 @@ folders. | User-facing API | Accepted | String Paths and Live Descriptors | Separates persisted field selectors from references to live model parameters. | [`string-paths-and-live-descriptors.md`](accepted/string-paths-and-live-descriptors.md) | | User-facing API | Accepted | Switchable Category API | Places multi-type category selectors on the owner and omits public selectors for fixed or single-type categories. | [`switchable-category-api.md`](accepted/switchable-category-api.md) | | User-facing API | Accepted | Switchable Category Owned Selectors | Moves the writable `type` selector and `show_supported()` onto the category itself; collapses the CIF duplication. | [`switchable-category-owned-selectors.md`](accepted/switchable-category-owned-selectors.md) | +| User-facing API | Accepted | Unified Pattern View | `pattern()` always renders available data, drops `include`, and unifies single- and three-panel figure sizing. | [`pattern-display-unification.md`](accepted/pattern-display-unification.md) | | User-facing API | Accepted | Value-Selector Discovery | Gives enumerated value fields a per-descriptor `show_supported()`, beside the three category-level selector families. | [`value-selector-discovery.md`](accepted/value-selector-discovery.md) | diff --git a/docs/dev/plans/pattern-display-unification.md b/docs/dev/plans/pattern-display-unification.md new file mode 100644 index 000000000..35c3f2f08 --- /dev/null +++ b/docs/dev/plans/pattern-display-unification.md @@ -0,0 +1,151 @@ +# Plan: Pattern Display Unification + +Follows [`AGENTS.md`](../../../AGENTS.md). No deliberate exceptions. + +## ADR + +Recorded as the +[`pattern-display-unification`](../adrs/accepted/pattern-display-unification.md) +ADR (Accepted), which supersedes the `include`-based pattern design in +[`display-ux.md`](../adrs/accepted/display-ux.md). Relates to +[`plotting-docs-performance.md`](../adrs/accepted/plotting-docs-performance.md) +(the lazy figure skeleton that exposed the height bug) and open issue +#93 (future of `show_residual`). + +## Branch / PR + +Feature slug `pattern-display-unification`. PR targets `develop`. +**Open:** confirm whether to branch off the current +`tutorial-project-outputs` working branch or start a fresh +`pattern-display-unification` branch before committing. + +## Background + +Four user-reported issues, all rooted in the split between the +single-panel path (`plot_meas`/`plot_calc` → `plot_powder`) and the +composite path (`build_powder_meas_vs_calc_figure`): + +1. **HTML height** — the single-panel Plotly figure sets no explicit + height, so the docs' lazy skeleton falls back to + `DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT = 600px` (the full 3-panel + height) instead of the ~458px main panel. +2. **X-axis offsets** — the single-panel path sets no x-range, so Plotly + auto-pads; the composite pins `(min, max)`. +3. **`include=('measured','excluded')` redundancy** — excluded regions + are a property of the experiment, not a viewing choice. +4. **Structure top margin** — the 3D scene sits flush under its header + line. + +## Decisions + +- **Drop `include` entirely.** + `pattern(expt_name, x_min=None, x_max=None, *, x=None)` always renders + all available content (the former `include='auto'` behaviour, which is + already proven). The cost — losing strict-subset views such as + measured-only once a calc exists — is accepted; `x_min`/`x_max` and + `x` still work. Can be re-added later only on a concrete need. +- **Excluded regions always shade** when defined on the experiment, in + every view; skipped only when a custom `x` axis is selected (the + overlay can't be mapped). The `'excluded'` token is removed. +- **Common rendering base via shared primitives.** The single-panel + `plot_powder` derives its figure height from + `_single_main_panel_height_pixels(...)` and its x-range from + `_composite_x_range(...)` — the same helpers the composite uses. + Because `_get_layout` already uses the composite margins + (`r:30, t:40, b:45`), a 1-panel view becomes the composite's main row + pixel-for-pixel. No builder merge (the composite hard-requires + `y_calc`); the shared helpers are the single source of truth. +- **Keep the internal availability engine** (`_pattern_option_statuses`, + `_auto_include`, `PatternOptionStatus`); only the user-facing + selection/validation/discovery layer is removed. +- The residual-fraction default moves to `plotters/base.py` so both the + facade (`plotting.py`) and backend (`plotly.py`) can import it without + a circular dependency. + +## Open questions + +- Branch choice (above). +- Should `pattern()` keep accepting a custom `x` axis variable? Assumed + **yes** (separate from `include`). + +## Concrete files likely to change + +- `src/easydiffraction/display/plotters/base.py` — add + `DEFAULT_RESIDUAL_HEIGHT_FRACTION`. +- `src/easydiffraction/display/plotters/plotly.py` — `plot_powder` + explicit height + tight x-range. +- `src/easydiffraction/display/plotting.py` — import the moved constant. +- `src/easydiffraction/project/display.py` — rewrite `pattern()`; remove + `include`, `_normalize_include`, `_validate_requested_include`, + `show_pattern_options`; simplify `_show_point_estimate_pattern`. +- `src/easydiffraction/display/structure/templates/structure.html.j2` — + scene top margin (done). +- `docs/dev/adrs/accepted/display-ux.md` — amend Pattern Display. +- `docs/dev/adrs/accepted/crysview-structure-visualization.md`, + `docs/docs/quick-reference/index.md` — drop stale + `show_pattern_options` references. +- `docs/docs/tutorials/ed-3.py`, `ed-9.py`, `ed-11.py`, `ed-13.py` (+ + regenerated `.ipynb`) — remove `include=`. +- Phase 2: `tests/unit/easydiffraction/project/test_display.py`, + `tests/integration/fitting/test_plotting.py`. + +## Implementation steps (Phase 1) + +- [x] **P1.1 — Unify single-panel sizing/x-range.** Move + `DEFAULT_RESIDUAL_HEIGHT_FRACTION` to `base.py`; `plot_powder` + sets explicit main-panel height and tight `(min,max)` x-range. + Fixes issues #1 and #2. Commit: + `Match single-panel pattern height and x-range to composite` +- [x] **P1.2 — Drop `include`; always render available content.** + Rewrite `pattern()`; remove the selection/validation/discovery + layer; always-shade excluded. Fixes issue #3. Commit: + `Always render available pattern content; drop include` +- [x] **P1.3 — Structure scene top margin.** Fixes issue #4. Commit: + `Add top margin above structure scene rectangle` +- [x] **P1.4 — Amend ADR and docs.** Update `display-ux.md`; drop stale + `show_pattern_options` references. Commit: + `Amend display-ux ADR for always-on pattern view` +- [x] **P1.5 — Update tutorials.** Remove `include=` from tutorial + sources; `pixi run notebook-prepare`. Commit: + `Drop include= from tutorials for unified pattern view` +- [x] **P1.6 — Phase 1 review gate.** Commit: + `Reach Phase 1 review gate` + +Each completed P1 step is staged with explicit paths and committed +locally before the next step (per `AGENTS.md` Commits). Stop after Phase +1 for review before Phase 2. + +## Phase 2 — Verification + +```bash +pixi run fix +pixi run check > /tmp/easydiffraction-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/easydiffraction-check.log; exit $check_exit_code +pixi run unit-tests > /tmp/easydiffraction-unit.log 2>&1; unit_tests_exit_code=$?; tail -n 200 /tmp/easydiffraction-unit.log; exit $unit_tests_exit_code +pixi run integration-tests +pixi run script-tests +``` + +Test updates: remove `include=`/`show_pattern_options` tests; add +coverage for always-shown excluded regions, single-panel height + tight +x-range matching the composite main row, and the nothing-to-plot error. + +## Status checklist + +- [x] Phase 1 implementation complete +- [ ] Phase 1 reviewed +- [x] Phase 2 verification complete + +## Suggested Pull Request + +**Title:** Simplify and unify the experiment pattern view + +**Description:** `project.display.pattern()` now always shows everything +the data supports — measured and calculated curves, the residual, Bragg +ticks, background, excluded regions, and uncertainty bands — so you no +longer pass `include=...` to assemble a view; just call +`pattern(expt_name=...)` and zoom with `x_min`/`x_max`. Excluded regions +are always shaded when defined. Single-panel and full three-panel charts +now share one layout, so a measured-only plot is exactly the top panel +of the full view — fixing the oversized height in the HTML docs and the +stray left/right margins. The 3D structure view also gains a little +breathing room below its title. diff --git a/docs/dev/plans/wyckoff-letter-detection.md b/docs/dev/plans/wyckoff-letter-detection.md index 2bfcb4bc5..094111680 100644 --- a/docs/dev/plans/wyckoff-letter-detection.md +++ b/docs/dev/plans/wyckoff-letter-detection.md @@ -11,6 +11,18 @@ ADR. No deliberate exception to `AGENTS.md` is taken. - [x] Phase 1 review gate - [x] Phase 2 — Verification (tests + `pixi` checks) +> **Post-implementation amendment (2026-06-04).** Two follow-up changes +> landed after this plan completed: (1) the `_space_group_Wyckoff.*` +> loop is no longer emitted in IUCr/HTML/TeX report output — the derived +> table is code-only; and (2) its exclusion from every serialization +> path now comes from the collection's own `_skip_cif_serialization()` +> hook (returning `True`), not from a +> `Structure._serializable_categories()` override, which was removed. +> References to `_serializable_categories()` and to "report-facing" / +> "emits the loop" below describe the original implementation and are +> superseded by this note and by the +> [ADR amendment](../adrs/accepted/wyckoff-letter-detection.md). + ## ADR This plan implements the @@ -105,13 +117,15 @@ rotation/translation parser. `_replace_from_space_group()` path rebuilds the derived collection through internal adoption. 10. **`space_group_Wyckoff` serialization policy.** The category is - model-owned and report-facing, but not persisted in project CIF. - `Structure._serializable_categories()` excludes it from - `structure.as_cif` / project saves, the IUCr/report writer emits the - `_space_group_Wyckoff.*` loop from the derived category, and - incoming `_space_group_Wyckoff.*` values are ignored/overwritten on - project load because the category is re-derived from the space - group. + model-owned and code-only: it is excluded from every serialization + path. The collection's own `_skip_cif_serialization()` hook (returns + `True`) suppresses its output in `structure.as_cif` / project saves + and in the report data context alike, and the IUCr writer does not + emit the `_space_group_Wyckoff.*` loop; incoming + `_space_group_Wyckoff.*` values are ignored/overwritten on project + load because the category is re-derived from the space group. + (Superseded the original `_serializable_categories()` + + report-emission design; see the Post-implementation amendment.) 11. **Allowed letters come from the current space group.** `_wyckoff_letter_allowed_values` returns `['', *tabulated_letters]` for supported groups and `[]` for absent groups. @@ -196,8 +210,9 @@ Phase 1 (implementation): - `src/easydiffraction/datablocks/structure/item/base.py` — add the `space_group_wyckoff` sibling category, expose it read-only, rebuild it from the current `space_group` before ordinary category update - hooks, and exclude it from project CIF through - `_serializable_categories()`. + hooks. (Project-CIF exclusion originally lived here via + `_serializable_categories()`; it now lives on the collection through + `_skip_cif_serialization()` — see the Post-implementation amendment.) - `src/easydiffraction/analysis/calculators/cryspy.py` — `_update_atom_multiplicity` reads `atom_site.multiplicity.value`. - `src/easydiffraction/io/cif/serialize.py` — only if atom-site CIF read @@ -288,7 +303,9 @@ The ADR commit + design-phase review/reply cleanup are handled by a read-only sibling category on `Structure`, rebuild it when structure categories update so it tracks the current space group, keep it empty for absent groups, and exclude it from project CIF - by overriding `_serializable_categories()`. Rebuild it from + by overriding `_serializable_categories()` (later replaced by the + collection's `_skip_cif_serialization()` hook; see the + Post-implementation amendment). Rebuild it from `Structure._update_categories()` before ordinary category update hooks, with no special `_update_priority`; atom-site detection reads `SPACE_GROUPS` through the crystallography helpers rather @@ -391,9 +408,11 @@ The ADR commit + design-phase review/reply cleanup are handled by is already automatic: P1.4 added the `multiplicity` descriptor with that CIF handler, and it is part of `AtomSite.parameters`, so the atom-site loop emits it (value `?` for untabulated - sites). The `_space_group_Wyckoff` loop exclusion is already - provided by P1.3's `Structure._serializable_categories` - override. No new write-side code was needed in P1.8. + sites). The `_space_group_Wyckoff` loop exclusion was originally + provided by P1.3's `Structure._serializable_categories` override + (later moved to the collection's `_skip_cif_serialization()` + hook; see the Post-implementation amendment). No new write-side + code was needed in P1.8. - **Read ignore of incoming `_space_group_Wyckoff.*`** is done by a no-op `SpaceGroupWyckoffCollection.from_cif` override (the structure read loop iterates *all* categories, including the diff --git a/docs/docs/api-reference/report.md b/docs/docs/api-reference/report.md index c33824c37..7a3ae20f9 100644 --- a/docs/docs/api-reference/report.md +++ b/docs/docs/api-reference/report.md @@ -1 +1 @@ -::: easydiffraction.project.categories.report.default.Report +::: easydiffraction.report diff --git a/docs/docs/assets/javascripts/extra.js b/docs/docs/assets/javascripts/extra.js index 6cf2caa6a..00894bb0f 100644 --- a/docs/docs/assets/javascripts/extra.js +++ b/docs/docs/assets/javascripts/extra.js @@ -194,14 +194,6 @@ }) } - function syncPandasTableTheme() { - const colors = themeColors() - document.querySelectorAll('.ed-themed-table').forEach((table) => { - // TABLE_AXIS_FRAME_CSS_VAR - table.style.setProperty('--ed-axis-frame-color', colors.axisFrame) - }) - } - function syncCrysviewTheme() { const next = themeName() document.querySelectorAll('.crysview').forEach((viewer) => { @@ -215,7 +207,6 @@ function syncThemeAwareOutputs() { syncPlotlyTheme() - syncPandasTableTheme() syncCrysviewTheme() } diff --git a/docs/docs/assets/stylesheets/extra.css b/docs/docs/assets/stylesheets/extra.css index 59e5b0929..ae3c9bccd 100644 --- a/docs/docs/assets/stylesheets/extra.css +++ b/docs/docs/assets/stylesheets/extra.css @@ -202,12 +202,33 @@ label.md-nav__title[for="__drawer"] { /* Change the overall width of the page */ .md-grid { - max-width: 1280px; + max-width: 125em; } -/* Needed for mkdocs-jupyter to show download and other buttons on top of the notebook */ -.md-content__button { - position: relative !important; +/* Keep prose line length stable when sidebars are hidden at narrower widths */ +.md-main .md-content > .md-content__inner { + width: min(100%, 45em); + margin-left: auto !important; + margin-right: auto !important; +} + +/* Notebook action buttons (Open in Colab, Download). The page title is the + notebook's own H1 rendered just below, so lay the buttons out as a + left-aligned row above it instead of the theme's default right float, + which overlapped long titles. */ +.md-content__nb-actions { + display: flex; + gap: 0.4rem; + /* Empty line of separation between the button row and the title */ + margin-bottom: 1.2em; + /* Keep the row above the notebook, which is pulled up via margin-top */ + position: relative; + z-index: 1; +} + +.md-content__nb-actions .md-content__button { + float: none; + margin: 0; } /* Background color of the search input field */ @@ -241,7 +262,9 @@ body[data-md-color-scheme="slate"] .jupyter-wrapper { .jp-Notebook { padding: 0 !important; - margin-top: -3em !important; + /* Pull the notebook up to absorb the jupyter wrapper's intrinsic top + whitespace while leaving a small gap below the action-button row. */ + margin-top: -0.5em !important; /* Ensure notebook content stretches across the page */ width: 100% !important; diff --git a/docs/docs/quick-reference/index.md b/docs/docs/quick-reference/index.md index 6fee6b1f5..b246586d1 100644 --- a/docs/docs/quick-reference/index.md +++ b/docs/docs/quick-reference/index.md @@ -136,7 +136,7 @@ experiment.linked_phases.create(id='lbco', scale=10.0) ## Inspect the Project -Show names, CIF text, and plotting options: +Show names and CIF text: ```python project.structures.show_names() @@ -144,8 +144,6 @@ project.experiments.show_names() structure.show_as_cif() experiment.show_as_cif() - -project.display.show_pattern_options(expt_name='hrpt') ``` Open the main display views: diff --git a/docs/docs/tutorials/ed-1.ipynb b/docs/docs/tutorials/ed-1.ipynb index 39de4d787..0ca03392d 100644 --- a/docs/docs/tutorials/ed-1.ipynb +++ b/docs/docs/tutorials/ed-1.ipynb @@ -75,8 +75,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Create minimal project without name and description\n", - "project = ed.Project()" + "# Create a minimal project with a short name\n", + "project = ed.Project(name='lbco_hrpt')" ] }, { @@ -288,6 +288,24 @@ "# Plot measured vs. calculated diffraction patterns\n", "project.display.pattern(expt_name='hrpt')" ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_1_lbco_hrpt')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-1.py b/docs/docs/tutorials/ed-1.py index 07e1830b3..59a574582 100644 --- a/docs/docs/tutorials/ed-1.py +++ b/docs/docs/tutorials/ed-1.py @@ -26,8 +26,8 @@ # ## 📦 Define Project # %% -# Create minimal project without name and description -project = ed.Project() +# Create a minimal project with a short name +project = ed.Project(name='lbco_hrpt') # %% [markdown] # ## 🧩 Define Structure @@ -115,3 +115,9 @@ # %% # Plot measured vs. calculated diffraction patterns project.display.pattern(expt_name='hrpt') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_1_lbco_hrpt') diff --git a/docs/docs/tutorials/ed-10.ipynb b/docs/docs/tutorials/ed-10.ipynb index 3815f24ea..0b309da49 100644 --- a/docs/docs/tutorials/ed-10.ipynb +++ b/docs/docs/tutorials/ed-10.ipynb @@ -75,7 +75,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='ni_pdf')" ] }, { @@ -261,6 +261,24 @@ "source": [ "project.display.pattern(expt_name='pdf')" ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_10_ni_pdf')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-10.py b/docs/docs/tutorials/ed-10.py index 751831508..46f54c02e 100644 --- a/docs/docs/tutorials/ed-10.py +++ b/docs/docs/tutorials/ed-10.py @@ -21,7 +21,7 @@ # ### Create Project # %% -project = ed.Project() +project = ed.Project(name='ni_pdf') # %% [markdown] # ### Add Structure @@ -101,3 +101,9 @@ # %% project.display.pattern(expt_name='pdf') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_10_ni_pdf') diff --git a/docs/docs/tutorials/ed-11.ipynb b/docs/docs/tutorials/ed-11.ipynb index b5d03cf56..65a861672 100644 --- a/docs/docs/tutorials/ed-11.ipynb +++ b/docs/docs/tutorials/ed-11.ipynb @@ -72,7 +72,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='si_nomad_pdf')" ] }, { @@ -289,7 +289,25 @@ "metadata": {}, "outputs": [], "source": [ - "project.display.pattern(expt_name='nomad', include=('measured', 'calculated'))" + "project.display.pattern(expt_name='nomad')" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_11_si_nomad_pdf')" ] } ], diff --git a/docs/docs/tutorials/ed-11.py b/docs/docs/tutorials/ed-11.py index e08e81d63..a11f1b233 100644 --- a/docs/docs/tutorials/ed-11.py +++ b/docs/docs/tutorials/ed-11.py @@ -18,7 +18,7 @@ # ### Create Project # %% -project = ed.Project() +project = ed.Project(name='si_nomad_pdf') # %% [markdown] # ### Set Plotting Engine @@ -111,4 +111,10 @@ # ### Display Pattern # %% -project.display.pattern(expt_name='nomad', include=('measured', 'calculated')) +project.display.pattern(expt_name='nomad') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_11_si_nomad_pdf') diff --git a/docs/docs/tutorials/ed-12.ipynb b/docs/docs/tutorials/ed-12.ipynb index ed2c0a0b5..2499a1981 100644 --- a/docs/docs/tutorials/ed-12.ipynb +++ b/docs/docs/tutorials/ed-12.ipynb @@ -75,7 +75,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='nacl_xray_pdf')" ] }, { @@ -331,6 +331,24 @@ "source": [ "project.display.pattern(expt_name='xray_pdf')" ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_12_nacl_xray_pdf')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-12.py b/docs/docs/tutorials/ed-12.py index 3add79927..8b8f31617 100644 --- a/docs/docs/tutorials/ed-12.py +++ b/docs/docs/tutorials/ed-12.py @@ -21,7 +21,7 @@ # ### Create Project # %% -project = ed.Project() +project = ed.Project(name='nacl_xray_pdf') # %% [markdown] # ### Set Plotting Engine @@ -131,3 +131,9 @@ # %% project.display.pattern(expt_name='xray_pdf') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_12_nacl_xray_pdf') diff --git a/docs/docs/tutorials/ed-13.ipynb b/docs/docs/tutorials/ed-13.ipynb index 9a84c90cd..4a13675e3 100644 --- a/docs/docs/tutorials/ed-13.ipynb +++ b/docs/docs/tutorials/ed-13.ipynb @@ -59,7 +59,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/first-steps/#importing-easydiffraction)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/first-steps/#importing-easydiffraction)\n", "for more details about importing the EasyDiffraction library and its\n", "components." ] @@ -107,7 +107,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/project/)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/project/)\n", "for more details about creating a project and its purpose in the\n", "analysis workflow." ] @@ -163,7 +163,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/)\n", "for more details about experiments and their purpose in the analysis\n", "workflow." ] @@ -221,7 +221,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#defining-an-experiment-manually)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#defining-an-experiment-manually)\n", "for more details about different types of experiments." ] }, @@ -263,11 +263,11 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#measured-data-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#measured-data-category)\n", "for more details about the measured data and its format.\n", "\n", "To visualize the measured data, we can use the `pattern` method of\n", - "the project's `display` facade with `include='measured'`." + "the project's `display` facade." ] }, { @@ -277,7 +277,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.display.pattern(expt_name='sim_si', include='measured')" + "project_1.display.pattern(expt_name='sim_si')" ] }, { @@ -309,7 +309,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#excluded-regions-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#excluded-regions-category)\n", "for more details about excluding regions from the measured data." ] }, @@ -341,7 +341,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.display.pattern(expt_name='sim_si', include=('measured', 'excluded'))" + "project_1.display.pattern(expt_name='sim_si')" ] }, { @@ -373,7 +373,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#instrument-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#instrument-category)\n", "for more details about the instrument parameters." ] }, @@ -460,7 +460,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/parameters/)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/parameters/)\n", "for more details about parameters in EasyDiffraction and their\n", "attributes." ] @@ -527,7 +527,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#peak-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#peak-category)\n", "for more details about the peak profile types." ] }, @@ -598,7 +598,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#background-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#background-category)\n", "for more details about the background and its types." ] }, @@ -655,15 +655,16 @@ "which is the length of the unit cell edge. The Si crystal structure\n", "has a single atom in the unit cell, which is located at the origin (0,\n", "0, 0) of the unit cell. The symmetry of this site is defined by the\n", - "Wyckoff letter 'a'. The atomic displacement parameter defines the\n", - "thermal vibrations of the atoms in the unit cell and is presented as\n", - "an isotropic parameter (B_iso).\n", + "Wyckoff letter 'a', which is assigned automatically based on the space\n", + "group and the atomic coordinates. The atomic displacement parameter\n", + "defines the thermal vibrations of the atoms in the unit cell and is\n", + "presented as an isotropic parameter (B_iso).\n", "\n", "Sometimes, the initial crystal structure parameters can be obtained\n", "from one of the crystallographic databases, like for example the\n", "Crystallography Open Database (COD). In this case, we use the COD\n", "entry for silicon as a reference for the initial crystal structure\n", - "model: https://www.crystallography.net/cod/4507226.html\n", + "model: https://www.crystallography.net/cod/9008565.html\n", "\n", "Usually, the crystal structure parameters are provided in a CIF file\n", "format, which is a standard format for crystallographic data. An\n", @@ -678,7 +679,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/data-format/)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/data-format/)\n", "for more details about the CIF format and its use in EasyDiffraction." ] }, @@ -691,7 +692,7 @@ "data_si\n", "\n", "_space_group.name_H-M_alt \"F d -3 m\"\n", - "_space_group.IT_coordinate_system_code 2\n", + "_space_group.IT_coordinate_system_code 1\n", "\n", "_cell.length_a 5.43\n", "_cell.length_b 5.43\n", @@ -706,11 +707,10 @@ "_atom_site.fract_x\n", "_atom_site.fract_y\n", "_atom_site.fract_z\n", - "_atom_site.wyckoff_letter\n", "_atom_site.occupancy\n", "_atom_site.ADP_type\n", "_atom_site.B_iso_or_equiv\n", - "Si Si 0.125 0.125 0.125 a 1.0 Biso 0.89\n", + "Si Si 0 0 0 1.0 Biso 0.89\n", "```" ] }, @@ -730,7 +730,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/)\n", "for more details about structures and their purpose in the data\n", "analysis workflow." ] @@ -767,7 +767,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#space-group-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#space-group-category)\n", "for more details about the space group." ] }, @@ -779,7 +779,7 @@ "outputs": [], "source": [ "project_1.structures['si'].space_group.name_h_m = 'F d -3 m'\n", - "project_1.structures['si'].space_group.it_coordinate_system_code = '2'" + "project_1.structures['si'].space_group.it_coordinate_system_code = '1'" ] }, { @@ -796,7 +796,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#cell-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#cell-category)\n", "for more details about the unit cell parameters." ] }, @@ -824,7 +824,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#atom-sites-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#atom-sites-category)\n", "for more details about the atom sites category." ] }, @@ -838,9 +838,10 @@ "project_1.structures['si'].atom_sites.create(\n", " label='Si',\n", " type_symbol='Si',\n", - " fract_x=0.125,\n", - " fract_y=0.125,\n", - " fract_z=0.125,\n", + " fract_x=0.0,\n", + " fract_y=0.0,\n", + " fract_z=0.0,\n", + " adp_type='Biso',\n", " adp_iso=0.89,\n", ")" ] @@ -885,7 +886,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#linked-phases-category)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#linked-phases-category)\n", "for more details about linking a structure to an experiment." ] }, @@ -944,7 +945,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/analysis/#minimization-optimization)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/analysis/#minimization-optimization)\n", "for more details about the fitting process in EasyDiffraction." ] }, @@ -1067,7 +1068,7 @@ "metadata": {}, "source": [ "📖 See\n", - "[documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/analysis/#perform-fit)\n", + "[documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/analysis/#perform-fit)\n", "for more details about the fitting process." ] }, @@ -1199,7 +1200,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_1.save_as(dir_path='data/powder_diffraction_Si')" + "project_1.save_as(dir_path='projects/ed_13_reference')" ] }, { @@ -1368,12 +1369,12 @@ "metadata": {}, "outputs": [], "source": [ - "project_2.display.pattern(expt_name='sim_lbco', include='measured')\n", + "project_2.display.pattern(expt_name='sim_lbco')\n", "\n", "project_2.experiments['sim_lbco'].excluded_regions.create(id='1', start=0, end=55000)\n", "project_2.experiments['sim_lbco'].excluded_regions.create(id='2', start=105500, end=200000)\n", "\n", - "project_2.display.pattern(expt_name='sim_lbco', include=('measured', 'excluded'))" + "project_2.display.pattern(expt_name='sim_lbco')" ] }, { @@ -1578,14 +1579,13 @@ "_atom_site.fract_x\n", "_atom_site.fract_y\n", "_atom_site.fract_z\n", - "_atom_site.wyckoff_letter\n", "_atom_site.occupancy\n", "_atom_site.ADP_type\n", "_atom_site.B_iso_or_equiv\n", - "La La 0.0 0.0 0.0 a 0.5 Biso 0.95\n", - "Ba Ba 0.0 0.0 0.0 a 0.5 Biso 0.95\n", - "Co Co 0.5 0.5 0.5 b 1.0 Biso 0.80\n", - "O O 0.0 0.5 0.5 c 1.0 Biso 1.66\n", + "La La 0.0 0.0 0.0 0.5 Biso 0.95\n", + "Ba Ba 0.0 0.0 0.0 0.5 Biso 0.95\n", + "Co Co 0.5 0.5 0.5 1.0 Biso 0.80\n", + "O O 0.0 0.5 0.5 1.0 Biso 1.66\n", "```" ] }, @@ -1804,8 +1804,9 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " adp_iso=0.95,\n", " occupancy=0.5,\n", + " adp_type='Biso',\n", + " adp_iso=0.95,\n", ")\n", "project_2.structures['lbco'].atom_sites.create(\n", " label='Ba',\n", @@ -1813,8 +1814,9 @@ " fract_x=0,\n", " fract_y=0,\n", " fract_z=0,\n", - " adp_iso=0.95,\n", " occupancy=0.5,\n", + " adp_type='Biso',\n", + " adp_iso=0.95,\n", ")\n", "project_2.structures['lbco'].atom_sites.create(\n", " label='Co',\n", @@ -1822,6 +1824,7 @@ " fract_x=0.5,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", + " adp_type='Biso',\n", " adp_iso=0.80,\n", ")\n", "project_2.structures['lbco'].atom_sites.create(\n", @@ -1830,6 +1833,7 @@ " fract_x=0,\n", " fract_y=0.5,\n", " fract_z=0.5,\n", + " adp_type='Biso',\n", " adp_iso=1.66,\n", ")" ] @@ -2631,7 +2635,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_2.save_as(dir_path='data/powder_diffraction_LBCO_Si')" + "project_2.save_as(dir_path='projects/ed_13_main')" ] }, { diff --git a/docs/docs/tutorials/ed-13.py b/docs/docs/tutorials/ed-13.py index 82970780f..d30af7865 100644 --- a/docs/docs/tutorials/ed-13.py +++ b/docs/docs/tutorials/ed-13.py @@ -29,7 +29,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/first-steps/#importing-easydiffraction) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/first-steps/#importing-easydiffraction) # for more details about importing the EasyDiffraction library and its # components. @@ -60,7 +60,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/project/) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/project/) # for more details about creating a project and its purpose in the # analysis workflow. @@ -87,7 +87,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/) # for more details about experiments and their purpose in the analysis # workflow. @@ -116,7 +116,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#defining-an-experiment-manually) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#defining-an-experiment-manually) # for more details about different types of experiments. # %% @@ -141,14 +141,14 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#measured-data-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#measured-data-category) # for more details about the measured data and its format. # # To visualize the measured data, we can use the `pattern` method of -# the project's `display` facade with `include='measured'`. +# the project's `display` facade. # %% -project_1.display.pattern(expt_name='sim_si', include='measured') +project_1.display.pattern(expt_name='sim_si') # %% [markdown] # If you zoom in on the highest TOF peak (around 120,000 μs), you will @@ -170,7 +170,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#excluded-regions-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#excluded-regions-category) # for more details about excluding regions from the measured data. # %% @@ -183,7 +183,7 @@ # the plot and is not used in the fitting process. # %% -project_1.display.pattern(expt_name='sim_si', include=('measured', 'excluded')) +project_1.display.pattern(expt_name='sim_si') # %% [markdown] # #### Set Instrument @@ -205,7 +205,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#instrument-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#instrument-category) # for more details about the instrument parameters. # %% @@ -251,7 +251,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/parameters/) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/parameters/) # for more details about parameters in EasyDiffraction and their # attributes. @@ -308,7 +308,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#peak-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#peak-category) # for more details about the peak profile types. # %% @@ -355,7 +355,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#background-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#background-category) # for more details about the background and its types. # %% @@ -393,15 +393,16 @@ # which is the length of the unit cell edge. The Si crystal structure # has a single atom in the unit cell, which is located at the origin (0, # 0, 0) of the unit cell. The symmetry of this site is defined by the -# Wyckoff letter 'a'. The atomic displacement parameter defines the -# thermal vibrations of the atoms in the unit cell and is presented as -# an isotropic parameter (B_iso). +# Wyckoff letter 'a', which is assigned automatically based on the space +# group and the atomic coordinates. The atomic displacement parameter +# defines the thermal vibrations of the atoms in the unit cell and is +# presented as an isotropic parameter (B_iso). # # Sometimes, the initial crystal structure parameters can be obtained # from one of the crystallographic databases, like for example the # Crystallography Open Database (COD). In this case, we use the COD # entry for silicon as a reference for the initial crystal structure -# model: https://www.crystallography.net/cod/4507226.html +# model: https://www.crystallography.net/cod/9008565.html # # Usually, the crystal structure parameters are provided in a CIF file # format, which is a standard format for crystallographic data. An @@ -411,7 +412,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/data-format/) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/data-format/) # for more details about the CIF format and its use in EasyDiffraction. # %% [markdown] @@ -419,7 +420,7 @@ # data_si # # _space_group.name_H-M_alt "F d -3 m" -# _space_group.IT_coordinate_system_code 2 +# _space_group.IT_coordinate_system_code 1 # # _cell.length_a 5.43 # _cell.length_b 5.43 @@ -434,11 +435,10 @@ # _atom_site.fract_x # _atom_site.fract_y # _atom_site.fract_z -# _atom_site.wyckoff_letter # _atom_site.occupancy # _atom_site.ADP_type # _atom_site.B_iso_or_equiv -# Si Si 0.125 0.125 0.125 a 1.0 Biso 0.89 +# Si Si 0 0 0 1.0 Biso 0.89 # ``` # %% [markdown] @@ -448,7 +448,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/) # for more details about structures and their purpose in the data # analysis workflow. @@ -463,19 +463,19 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#space-group-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#space-group-category) # for more details about the space group. # %% project_1.structures['si'].space_group.name_h_m = 'F d -3 m' -project_1.structures['si'].space_group.it_coordinate_system_code = '2' +project_1.structures['si'].space_group.it_coordinate_system_code = '1' # %% [markdown] # #### Set Unit Cell # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#cell-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#cell-category) # for more details about the unit cell parameters. # %% @@ -486,16 +486,17 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/structure/#atom-sites-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/structure/#atom-sites-category) # for more details about the atom sites category. # %% project_1.structures['si'].atom_sites.create( label='Si', type_symbol='Si', - fract_x=0.125, - fract_y=0.125, - fract_z=0.125, + fract_x=0.0, + fract_y=0.0, + fract_z=0.0, + adp_type='Biso', adp_iso=0.89, ) @@ -518,7 +519,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/experiment/#linked-phases-category) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/experiment/#linked-phases-category) # for more details about linking a structure to an experiment. # %% @@ -553,7 +554,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/analysis/#minimization-optimization) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/analysis/#minimization-optimization) # for more details about the fitting process in EasyDiffraction. # %% [markdown] @@ -625,7 +626,7 @@ # %% [markdown] tags=["doc-link"] # 📖 See -# [documentation](https://docs.easydiffraction.org/lib/user-guide/analysis-workflow/analysis/#perform-fit) +# [documentation](https://docs.easydiffraction.org/lib/latest/user-guide/analysis-workflow/analysis/#perform-fit) # for more details about the fitting process. # %% @@ -709,7 +710,7 @@ # directory specified by the `dir_path` attribute of the project object. # %% -project_1.save_as(dir_path='data/powder_diffraction_Si') +project_1.save_as(dir_path='projects/ed_13_reference') # %% [markdown] # ## 💪 Exercise: Complex Fit – LBCO @@ -797,12 +798,12 @@ # **Solution:** # %% tags=["solution", "hide-input"] -project_2.display.pattern(expt_name='sim_lbco', include='measured') +project_2.display.pattern(expt_name='sim_lbco') project_2.experiments['sim_lbco'].excluded_regions.create(id='1', start=0, end=55000) project_2.experiments['sim_lbco'].excluded_regions.create(id='2', start=105500, end=200000) -project_2.display.pattern(expt_name='sim_lbco', include=('measured', 'excluded')) +project_2.display.pattern(expt_name='sim_lbco') # %% [markdown] # #### Exercise 2.2: Set Instrument @@ -916,14 +917,13 @@ # _atom_site.fract_x # _atom_site.fract_y # _atom_site.fract_z -# _atom_site.wyckoff_letter # _atom_site.occupancy # _atom_site.ADP_type # _atom_site.B_iso_or_equiv -# La La 0.0 0.0 0.0 a 0.5 Biso 0.95 -# Ba Ba 0.0 0.0 0.0 a 0.5 Biso 0.95 -# Co Co 0.5 0.5 0.5 b 1.0 Biso 0.80 -# O O 0.0 0.5 0.5 c 1.0 Biso 1.66 +# La La 0.0 0.0 0.0 0.5 Biso 0.95 +# Ba Ba 0.0 0.0 0.0 0.5 Biso 0.95 +# Co Co 0.5 0.5 0.5 1.0 Biso 0.80 +# O O 0.0 0.5 0.5 1.0 Biso 1.66 # ``` # %% [markdown] @@ -1029,8 +1029,9 @@ fract_x=0, fract_y=0, fract_z=0, - adp_iso=0.95, occupancy=0.5, + adp_type='Biso', + adp_iso=0.95, ) project_2.structures['lbco'].atom_sites.create( label='Ba', @@ -1038,8 +1039,9 @@ fract_x=0, fract_y=0, fract_z=0, - adp_iso=0.95, occupancy=0.5, + adp_type='Biso', + adp_iso=0.95, ) project_2.structures['lbco'].atom_sites.create( label='Co', @@ -1047,6 +1049,7 @@ fract_x=0.5, fract_y=0.5, fract_z=0.5, + adp_type='Biso', adp_iso=0.80, ) project_2.structures['lbco'].atom_sites.create( @@ -1055,6 +1058,7 @@ fract_x=0, fract_y=0.5, fract_z=0.5, + adp_type='Biso', adp_iso=1.66, ) @@ -1478,7 +1482,7 @@ # the analysis. # %% -project_2.save_as(dir_path='data/powder_diffraction_LBCO_Si') +project_2.save_as(dir_path='projects/ed_13_main') # %% [markdown] # #### Final Remarks diff --git a/docs/docs/tutorials/ed-14.ipynb b/docs/docs/tutorials/ed-14.ipynb index 2c744b0da..9488915db 100644 --- a/docs/docs/tutorials/ed-14.ipynb +++ b/docs/docs/tutorials/ed-14.ipynb @@ -63,14 +63,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Create minimal project without name and description\n", + "# Create a minimal project with a short name\n", "project = ed.Project(name='tbti_heidi')\n", "project.info.title = 'Tb2Ti2O7 at HEiDi@FRMII'\n", "project.info.description = \"\"\"This project demonstrates a standard\n", "refinement of the crystal structure of Tb2Ti2O7 using single crystal \n", "neutron diffraction data from HEiDi at FRM II.\"\"\"\n", "\n", - "project.save_as('projects/tbti_heidi')" + "project.save_as(dir_path='projects/ed_14_tbti_heidi')" ] }, { diff --git a/docs/docs/tutorials/ed-14.py b/docs/docs/tutorials/ed-14.py index f0b214526..03a2b329d 100644 --- a/docs/docs/tutorials/ed-14.py +++ b/docs/docs/tutorials/ed-14.py @@ -14,14 +14,14 @@ # ## 📦 Define Project # %% -# Create minimal project without name and description +# Create a minimal project with a short name project = ed.Project(name='tbti_heidi') project.info.title = 'Tb2Ti2O7 at HEiDi@FRMII' project.info.description = """This project demonstrates a standard refinement of the crystal structure of Tb2Ti2O7 using single crystal neutron diffraction data from HEiDi at FRM II.""" -project.save_as('projects/tbti_heidi') +project.save_as(dir_path='projects/ed_14_tbti_heidi') # %% [markdown] # ## 🧩 Define Structure diff --git a/docs/docs/tutorials/ed-15.ipynb b/docs/docs/tutorials/ed-15.ipynb index 821429bd9..2fcc797fa 100644 --- a/docs/docs/tutorials/ed-15.ipynb +++ b/docs/docs/tutorials/ed-15.ipynb @@ -63,8 +63,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Create minimal project without name and description\n", - "project = ed.Project()" + "# Create a minimal project with a short name\n", + "project = ed.Project(name='taurine_senju')" ] }, { @@ -425,6 +425,24 @@ "source": [ "structure.show_as_cif()" ] + }, + { + "cell_type": "markdown", + "id": "41", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_15_taurine_senju')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-15.py b/docs/docs/tutorials/ed-15.py index 2fcf91934..c8d799405 100644 --- a/docs/docs/tutorials/ed-15.py +++ b/docs/docs/tutorials/ed-15.py @@ -14,8 +14,8 @@ # ## 📦 Define Project # %% -# Create minimal project without name and description -project = ed.Project() +# Create a minimal project with a short name +project = ed.Project(name='taurine_senju') # %% [markdown] # ## 🧩 Define Structure @@ -140,3 +140,9 @@ # %% structure.show_as_cif() + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_15_taurine_senju') diff --git a/docs/docs/tutorials/ed-16.ipynb b/docs/docs/tutorials/ed-16.ipynb index 09c816f05..0e26f35e7 100644 --- a/docs/docs/tutorials/ed-16.ipynb +++ b/docs/docs/tutorials/ed-16.ipynb @@ -374,7 +374,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='si_bragg_pdf')" ] }, { @@ -618,6 +618,24 @@ "source": [ "project.display.pattern(expt_name='nomad')" ] + }, + { + "cell_type": "markdown", + "id": "58", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_16_si_bragg_pdf')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-16.py b/docs/docs/tutorials/ed-16.py index 89e60625f..1a811a2a8 100644 --- a/docs/docs/tutorials/ed-16.py +++ b/docs/docs/tutorials/ed-16.py @@ -157,7 +157,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='si_bragg_pdf') # %% [markdown] # ### Add Structure @@ -254,3 +254,9 @@ # %% project.display.pattern(expt_name='nomad') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_16_si_bragg_pdf') diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb index 351290746..81bce1da7 100644 --- a/docs/docs/tutorials/ed-17.ipynb +++ b/docs/docs/tutorials/ed-17.ipynb @@ -69,7 +69,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project(name='cosio_d20')\n", + "project = ed.Project(name='cosio_d20_scan')\n", "analysis = project.analysis\n", "display = project.display" ] @@ -90,7 +90,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as(dir_path='projects/cosio_d20_scan')" + "project.save_as(dir_path='projects/ed_17_cosio_d20_scan')" ] }, { diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py index eb3605e9d..7391e1582 100644 --- a/docs/docs/tutorials/ed-17.py +++ b/docs/docs/tutorials/ed-17.py @@ -20,7 +20,7 @@ # and other related components. # %% -project = ed.Project(name='cosio_d20') +project = ed.Project(name='cosio_d20_scan') analysis = project.analysis display = project.display @@ -29,7 +29,7 @@ # results can be written to `analysis/results.csv`. # %% -project.save_as(dir_path='projects/cosio_d20_scan') +project.save_as(dir_path='projects/ed_17_cosio_d20_scan') # %% [markdown] # ## 🧩 Define Structure diff --git a/docs/docs/tutorials/ed-18.ipynb b/docs/docs/tutorials/ed-18.ipynb index 86d6cb69d..0299b4487 100644 --- a/docs/docs/tutorials/ed-18.ipynb +++ b/docs/docs/tutorials/ed-18.ipynb @@ -181,6 +181,24 @@ "source": [ "project.display.pattern(expt_name='hrpt')" ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_18_lbco_hrpt')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-18.py b/docs/docs/tutorials/ed-18.py index f3caa2e09..c65c0243f 100644 --- a/docs/docs/tutorials/ed-18.py +++ b/docs/docs/tutorials/ed-18.py @@ -59,3 +59,9 @@ # %% project.display.pattern(expt_name='hrpt') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_18_lbco_hrpt') diff --git a/docs/docs/tutorials/ed-2.ipynb b/docs/docs/tutorials/ed-2.ipynb index 9b1872b5b..60ba1b82c 100644 --- a/docs/docs/tutorials/ed-2.ipynb +++ b/docs/docs/tutorials/ed-2.ipynb @@ -80,7 +80,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='lbco_hrpt')" ] }, { @@ -549,7 +549,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/lbco_hrpt')" + "project.save_as(dir_path='projects/ed_2_lbco_hrpt')" ] } ], diff --git a/docs/docs/tutorials/ed-2.py b/docs/docs/tutorials/ed-2.py index 008ca5d0f..e2207a8b2 100644 --- a/docs/docs/tutorials/ed-2.py +++ b/docs/docs/tutorials/ed-2.py @@ -31,7 +31,7 @@ # ## 📦 Define Project # %% -project = ed.Project() +project = ed.Project(name='lbco_hrpt') # %% [markdown] # ## 🧩 Define Structure @@ -234,4 +234,4 @@ # ## 💾 Save Project # %% -project.save_as('projects/lbco_hrpt') +project.save_as(dir_path='projects/ed_2_lbco_hrpt') diff --git a/docs/docs/tutorials/ed-20.ipynb b/docs/docs/tutorials/ed-20.ipynb index 6dc22803e..98d9572a7 100644 --- a/docs/docs/tutorials/ed-20.ipynb +++ b/docs/docs/tutorials/ed-20.ipynb @@ -456,8 +456,8 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project(name='beer')\n", - "project.save_as(dir_path='projects/beer_mcstas')" + "project = Project(name='beer_mcstas')\n", + "project.save_as(dir_path='projects/ed_20_beer_mcstas')" ] }, { diff --git a/docs/docs/tutorials/ed-20.py b/docs/docs/tutorials/ed-20.py index 5b4b6fe03..bc5b1e644 100644 --- a/docs/docs/tutorials/ed-20.py +++ b/docs/docs/tutorials/ed-20.py @@ -222,8 +222,8 @@ # ### Create Project # %% -project = Project(name='beer') -project.save_as(dir_path='projects/beer_mcstas') +project = Project(name='beer_mcstas') +project.save_as(dir_path='projects/ed_20_beer_mcstas') # %% [markdown] # ### Add Structures diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index 697851df6..0b4ab246e 100644 --- a/docs/docs/tutorials/ed-21.ipynb +++ b/docs/docs/tutorials/ed-21.ipynb @@ -87,7 +87,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='lbco_hrpt_bumps_dream')" ] }, { @@ -97,7 +97,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/lbco_hrpt_bumps-dream')" + "project.save_as(dir_path='projects/ed_21_lbco_hrpt_bumps_dream')" ] }, { @@ -637,7 +637,8 @@ "outputs": [], "source": [ "project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000\n", - "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600" + "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600\n", + "project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output" ] }, { diff --git a/docs/docs/tutorials/ed-21.py b/docs/docs/tutorials/ed-21.py index efce4cacd..002f3ba4d 100644 --- a/docs/docs/tutorials/ed-21.py +++ b/docs/docs/tutorials/ed-21.py @@ -38,10 +38,10 @@ # it later if needed. # %% -project = ed.Project() +project = ed.Project(name='lbco_hrpt_bumps_dream') # %% -project.save_as('projects/lbco_hrpt_bumps-dream') +project.save_as(dir_path='projects/ed_21_lbco_hrpt_bumps_dream') # %% [markdown] # ## 🧩 Define Structure @@ -306,6 +306,7 @@ # %% project.analysis.minimizer.sampling_steps = 100 # lower than the default 3000 project.analysis.minimizer.burn_in_steps = 20 # lower than the default 600 +project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-22.ipynb b/docs/docs/tutorials/ed-22.ipynb index 1a70e189e..d7f559090 100644 --- a/docs/docs/tutorials/ed-22.ipynb +++ b/docs/docs/tutorials/ed-22.ipynb @@ -84,7 +84,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='tbti_heidi_emcee')" ] }, { @@ -94,7 +94,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/tbti_heidi_emcee')" + "project.save_as(dir_path='projects/ed_22_tbti_heidi_emcee')" ] }, { @@ -510,7 +510,8 @@ "source": [ "project.analysis.minimizer.sampling_steps = 500 # lower than the default 3000\n", "project.analysis.minimizer.burn_in_steps = 100 # lower than the default 600\n", - "project.analysis.minimizer.population_size = 16 # lower than the default 32" + "project.analysis.minimizer.population_size = 16 # lower than the default 32\n", + "project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output" ] }, { diff --git a/docs/docs/tutorials/ed-22.py b/docs/docs/tutorials/ed-22.py index e1b3f8abe..d9dcad9ca 100644 --- a/docs/docs/tutorials/ed-22.py +++ b/docs/docs/tutorials/ed-22.py @@ -35,10 +35,10 @@ # workflow inside this object. # %% -project = ed.Project() +project = ed.Project(name='tbti_heidi_emcee') # %% -project.save_as('projects/tbti_heidi_emcee') +project.save_as(dir_path='projects/ed_22_tbti_heidi_emcee') # %% [markdown] # ## 🧩 Define Structure @@ -229,6 +229,7 @@ project.analysis.minimizer.sampling_steps = 500 # lower than the default 3000 project.analysis.minimizer.burn_in_steps = 100 # lower than the default 600 project.analysis.minimizer.population_size = 16 # lower than the default 32 +project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-23.ipynb b/docs/docs/tutorials/ed-23.ipynb index 368150181..fcca74451 100644 --- a/docs/docs/tutorials/ed-23.ipynb +++ b/docs/docs/tutorials/ed-23.ipynb @@ -258,6 +258,24 @@ "source": [ "project.display.fit.series(versus=temperature)" ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_23_cosio_d20_scan')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-23.py b/docs/docs/tutorials/ed-23.py index f7e01bc66..59ee1c6ef 100644 --- a/docs/docs/tutorials/ed-23.py +++ b/docs/docs/tutorials/ed-23.py @@ -102,3 +102,9 @@ # %% project.display.fit.series(versus=temperature) + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_23_cosio_d20_scan') diff --git a/docs/docs/tutorials/ed-24.ipynb b/docs/docs/tutorials/ed-24.ipynb index e21fd7ed6..b4dbb1fee 100644 --- a/docs/docs/tutorials/ed-24.ipynb +++ b/docs/docs/tutorials/ed-24.ipynb @@ -245,6 +245,24 @@ "source": [ "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_24_lbco_hrpt_bumps_dream')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-24.py b/docs/docs/tutorials/ed-24.py index 95cfff7d7..984706474 100644 --- a/docs/docs/tutorials/ed-24.py +++ b/docs/docs/tutorials/ed-24.py @@ -94,3 +94,9 @@ # %% project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_24_lbco_hrpt_bumps_dream') diff --git a/docs/docs/tutorials/ed-25.ipynb b/docs/docs/tutorials/ed-25.ipynb index 7f94ff8d9..b7757aa2f 100644 --- a/docs/docs/tutorials/ed-25.ipynb +++ b/docs/docs/tutorials/ed-25.ipynb @@ -87,7 +87,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = ed.Project()" + "project = ed.Project(name='lbco_hrpt_emcee')" ] }, { @@ -97,7 +97,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/lbco_hrpt_emcee')" + "project.save_as(dir_path='projects/ed_25_lbco_hrpt_emcee')" ] }, { @@ -623,7 +623,8 @@ "source": [ "project.analysis.minimizer.sampling_steps = 100 # lower than the default 5000\n", "project.analysis.minimizer.burn_in_steps = 20 # lower than the default 1000\n", - "project.analysis.minimizer.population_size = 16 # lower than the default 32" + "project.analysis.minimizer.population_size = 16 # lower than the default 32\n", + "project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output" ] }, { diff --git a/docs/docs/tutorials/ed-25.py b/docs/docs/tutorials/ed-25.py index 8806d05cc..662615def 100644 --- a/docs/docs/tutorials/ed-25.py +++ b/docs/docs/tutorials/ed-25.py @@ -38,10 +38,10 @@ # it later if needed. # %% -project = ed.Project() +project = ed.Project(name='lbco_hrpt_emcee') # %% -project.save_as('projects/lbco_hrpt_emcee') +project.save_as(dir_path='projects/ed_25_lbco_hrpt_emcee') # %% [markdown] # ## 🧩 Define Structure @@ -299,6 +299,7 @@ project.analysis.minimizer.sampling_steps = 100 # lower than the default 5000 project.analysis.minimizer.burn_in_steps = 20 # lower than the default 1000 project.analysis.minimizer.population_size = 16 # lower than the default 32 +project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output # %% project.analysis.fit() diff --git a/docs/docs/tutorials/ed-26.ipynb b/docs/docs/tutorials/ed-26.ipynb index f9a778aa3..1e4460336 100644 --- a/docs/docs/tutorials/ed-26.ipynb +++ b/docs/docs/tutorials/ed-26.ipynb @@ -291,6 +291,7 @@ "metadata": {}, "outputs": [], "source": [ + "project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output\n", "project.analysis.fit(resume=True, extra_steps=100)" ] }, @@ -343,6 +344,24 @@ "source": [ "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_26_lbco_hrpt_emcee')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-26.py b/docs/docs/tutorials/ed-26.py index c3d994d62..d3d9be817 100644 --- a/docs/docs/tutorials/ed-26.py +++ b/docs/docs/tutorials/ed-26.py @@ -124,6 +124,7 @@ # convergence and better posterior resolution. # %% +project.analysis.minimizer.random_seed = 42 # fixed seed for reproducible output project.analysis.fit(resume=True, extra_steps=100) # %% @@ -142,3 +143,9 @@ # %% project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_26_lbco_hrpt_emcee') diff --git a/docs/docs/tutorials/ed-3.ipynb b/docs/docs/tutorials/ed-3.ipynb index cce439297..2ed3d57e0 100644 --- a/docs/docs/tutorials/ed-3.ipynb +++ b/docs/docs/tutorials/ed-3.ipynb @@ -148,7 +148,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as(dir_path='projects/lbco_hrpt')" + "project.save_as(dir_path='projects/ed_3_lbco_hrpt')" ] }, { @@ -594,7 +594,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.display.pattern(expt_name='hrpt', include='measured')" + "project.display.pattern(expt_name='hrpt')" ] }, { @@ -907,24 +907,6 @@ "cell_type": "markdown", "id": "85", "metadata": {}, - "source": [ - "### Show Calculated Data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "86", - "metadata": {}, - "outputs": [], - "source": [ - "project.display.pattern(expt_name='hrpt', include='calculated')" - ] - }, - { - "cell_type": "markdown", - "id": "87", - "metadata": {}, "source": [ "### Display Pattern" ] @@ -932,7 +914,7 @@ { "cell_type": "code", "execution_count": null, - "id": "88", + "id": "86", "metadata": {}, "outputs": [], "source": [ @@ -942,7 +924,7 @@ { "cell_type": "code", "execution_count": null, - "id": "89", + "id": "87", "metadata": {}, "outputs": [], "source": [ @@ -951,7 +933,7 @@ }, { "cell_type": "markdown", - "id": "90", + "id": "88", "metadata": {}, "source": [ "### Display Parameters\n", @@ -962,7 +944,7 @@ { "cell_type": "code", "execution_count": null, - "id": "91", + "id": "89", "metadata": {}, "outputs": [], "source": [ @@ -971,7 +953,7 @@ }, { "cell_type": "markdown", - "id": "92", + "id": "90", "metadata": {}, "source": [ "Show all fittable parameters." @@ -980,7 +962,7 @@ { "cell_type": "code", "execution_count": null, - "id": "93", + "id": "91", "metadata": {}, "outputs": [], "source": [ @@ -989,7 +971,7 @@ }, { "cell_type": "markdown", - "id": "94", + "id": "92", "metadata": {}, "source": [ "Show only free parameters." @@ -998,7 +980,7 @@ { "cell_type": "code", "execution_count": null, - "id": "95", + "id": "93", "metadata": {}, "outputs": [], "source": [ @@ -1007,7 +989,7 @@ }, { "cell_type": "markdown", - "id": "96", + "id": "94", "metadata": {}, "source": [ "Show how to access parameters in the code." @@ -1016,7 +998,7 @@ { "cell_type": "code", "execution_count": null, - "id": "97", + "id": "95", "metadata": {}, "outputs": [], "source": [ @@ -1025,7 +1007,7 @@ }, { "cell_type": "markdown", - "id": "98", + "id": "96", "metadata": {}, "source": [ "### Set Fit Mode\n", @@ -1036,7 +1018,7 @@ { "cell_type": "code", "execution_count": null, - "id": "99", + "id": "97", "metadata": {}, "outputs": [], "source": [ @@ -1045,7 +1027,7 @@ }, { "cell_type": "markdown", - "id": "100", + "id": "98", "metadata": {}, "source": [ "Select desired fit mode." @@ -1054,7 +1036,7 @@ { "cell_type": "code", "execution_count": null, - "id": "101", + "id": "99", "metadata": {}, "outputs": [], "source": [ @@ -1063,7 +1045,7 @@ }, { "cell_type": "markdown", - "id": "102", + "id": "100", "metadata": {}, "source": [ "### Set Minimizer\n", @@ -1074,7 +1056,7 @@ { "cell_type": "code", "execution_count": null, - "id": "103", + "id": "101", "metadata": {}, "outputs": [], "source": [ @@ -1083,7 +1065,7 @@ }, { "cell_type": "markdown", - "id": "104", + "id": "102", "metadata": {}, "source": [ "Select desired fitting engine." @@ -1092,7 +1074,7 @@ { "cell_type": "code", "execution_count": null, - "id": "105", + "id": "103", "metadata": {}, "outputs": [], "source": [ @@ -1101,7 +1083,7 @@ }, { "cell_type": "markdown", - "id": "106", + "id": "104", "metadata": {}, "source": [ "### Perform Fit 1/5\n", @@ -1112,7 +1094,7 @@ { "cell_type": "code", "execution_count": null, - "id": "107", + "id": "105", "metadata": {}, "outputs": [], "source": [ @@ -1121,7 +1103,7 @@ }, { "cell_type": "markdown", - "id": "108", + "id": "106", "metadata": {}, "source": [ "Set experiment parameters to be refined." @@ -1130,7 +1112,7 @@ { "cell_type": "code", "execution_count": null, - "id": "109", + "id": "107", "metadata": {}, "outputs": [], "source": [ @@ -1145,7 +1127,7 @@ }, { "cell_type": "markdown", - "id": "110", + "id": "108", "metadata": {}, "source": [ "Show free parameters after selection." @@ -1154,7 +1136,7 @@ { "cell_type": "code", "execution_count": null, - "id": "111", + "id": "109", "metadata": {}, "outputs": [], "source": [ @@ -1163,7 +1145,7 @@ }, { "cell_type": "markdown", - "id": "112", + "id": "110", "metadata": {}, "source": [ "#### Run Fitting" @@ -1172,7 +1154,7 @@ { "cell_type": "code", "execution_count": null, - "id": "113", + "id": "111", "metadata": {}, "outputs": [], "source": [ @@ -1182,7 +1164,7 @@ }, { "cell_type": "markdown", - "id": "114", + "id": "112", "metadata": {}, "source": [ "#### Display Pattern" @@ -1191,7 +1173,7 @@ { "cell_type": "code", "execution_count": null, - "id": "115", + "id": "113", "metadata": {}, "outputs": [], "source": [ @@ -1201,7 +1183,7 @@ { "cell_type": "code", "execution_count": null, - "id": "116", + "id": "114", "metadata": {}, "outputs": [], "source": [ @@ -1210,7 +1192,7 @@ }, { "cell_type": "markdown", - "id": "117", + "id": "115", "metadata": {}, "source": [ "### Perform Fit 2/5\n", @@ -1221,7 +1203,7 @@ { "cell_type": "code", "execution_count": null, - "id": "118", + "id": "116", "metadata": {}, "outputs": [], "source": [ @@ -1233,7 +1215,7 @@ }, { "cell_type": "markdown", - "id": "119", + "id": "117", "metadata": {}, "source": [ "Show free parameters after selection." @@ -1242,7 +1224,7 @@ { "cell_type": "code", "execution_count": null, - "id": "120", + "id": "118", "metadata": {}, "outputs": [], "source": [ @@ -1251,7 +1233,7 @@ }, { "cell_type": "markdown", - "id": "121", + "id": "119", "metadata": {}, "source": [ "#### Run Fitting" @@ -1260,7 +1242,7 @@ { "cell_type": "code", "execution_count": null, - "id": "122", + "id": "120", "metadata": {}, "outputs": [], "source": [ @@ -1270,7 +1252,7 @@ }, { "cell_type": "markdown", - "id": "123", + "id": "121", "metadata": {}, "source": [ "#### Display Pattern" @@ -1279,7 +1261,7 @@ { "cell_type": "code", "execution_count": null, - "id": "124", + "id": "122", "metadata": {}, "outputs": [], "source": [ @@ -1289,7 +1271,7 @@ { "cell_type": "code", "execution_count": null, - "id": "125", + "id": "123", "metadata": {}, "outputs": [], "source": [ @@ -1298,7 +1280,7 @@ }, { "cell_type": "markdown", - "id": "126", + "id": "124", "metadata": {}, "source": [ "#### Save Project State" @@ -1307,7 +1289,7 @@ { "cell_type": "code", "execution_count": null, - "id": "127", + "id": "125", "metadata": {}, "outputs": [], "source": [ @@ -1316,7 +1298,7 @@ }, { "cell_type": "markdown", - "id": "128", + "id": "126", "metadata": {}, "source": [ "### Perform Fit 3/5\n", @@ -1327,7 +1309,7 @@ { "cell_type": "code", "execution_count": null, - "id": "129", + "id": "127", "metadata": {}, "outputs": [], "source": [ @@ -1339,7 +1321,7 @@ }, { "cell_type": "markdown", - "id": "130", + "id": "128", "metadata": {}, "source": [ "Show free parameters after selection." @@ -1348,7 +1330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "131", + "id": "129", "metadata": {}, "outputs": [], "source": [ @@ -1357,7 +1339,7 @@ }, { "cell_type": "markdown", - "id": "132", + "id": "130", "metadata": {}, "source": [ "#### Run Fitting" @@ -1366,7 +1348,7 @@ { "cell_type": "code", "execution_count": null, - "id": "133", + "id": "131", "metadata": {}, "outputs": [], "source": [ @@ -1376,7 +1358,7 @@ }, { "cell_type": "markdown", - "id": "134", + "id": "132", "metadata": {}, "source": [ "#### Display Pattern" @@ -1385,7 +1367,7 @@ { "cell_type": "code", "execution_count": null, - "id": "135", + "id": "133", "metadata": {}, "outputs": [], "source": [ @@ -1395,7 +1377,7 @@ { "cell_type": "code", "execution_count": null, - "id": "136", + "id": "134", "metadata": {}, "outputs": [], "source": [ @@ -1404,7 +1386,7 @@ }, { "cell_type": "markdown", - "id": "137", + "id": "135", "metadata": {}, "source": [ "### Perform Fit 4/5\n", @@ -1417,7 +1399,7 @@ { "cell_type": "code", "execution_count": null, - "id": "138", + "id": "136", "metadata": {}, "outputs": [], "source": [ @@ -1433,7 +1415,7 @@ }, { "cell_type": "markdown", - "id": "139", + "id": "137", "metadata": {}, "source": [ "Set constraints." @@ -1442,7 +1424,7 @@ { "cell_type": "code", "execution_count": null, - "id": "140", + "id": "138", "metadata": {}, "outputs": [], "source": [ @@ -1451,7 +1433,7 @@ }, { "cell_type": "markdown", - "id": "141", + "id": "139", "metadata": {}, "source": [ "Show defined constraints." @@ -1460,7 +1442,7 @@ { "cell_type": "code", "execution_count": null, - "id": "142", + "id": "140", "metadata": {}, "outputs": [], "source": [ @@ -1469,7 +1451,7 @@ }, { "cell_type": "markdown", - "id": "143", + "id": "141", "metadata": {}, "source": [ "Show free parameters." @@ -1478,7 +1460,7 @@ { "cell_type": "code", "execution_count": null, - "id": "144", + "id": "142", "metadata": {}, "outputs": [], "source": [ @@ -1487,7 +1469,7 @@ }, { "cell_type": "markdown", - "id": "145", + "id": "143", "metadata": {}, "source": [ "#### Run Fitting" @@ -1496,7 +1478,7 @@ { "cell_type": "code", "execution_count": null, - "id": "146", + "id": "144", "metadata": {}, "outputs": [], "source": [ @@ -1506,7 +1488,7 @@ }, { "cell_type": "markdown", - "id": "147", + "id": "145", "metadata": {}, "source": [ "#### Display Pattern" @@ -1515,7 +1497,7 @@ { "cell_type": "code", "execution_count": null, - "id": "148", + "id": "146", "metadata": {}, "outputs": [], "source": [ @@ -1525,7 +1507,7 @@ { "cell_type": "code", "execution_count": null, - "id": "149", + "id": "147", "metadata": {}, "outputs": [], "source": [ @@ -1534,7 +1516,7 @@ }, { "cell_type": "markdown", - "id": "150", + "id": "148", "metadata": {}, "source": [ "### Perform Fit 5/5\n", @@ -1547,7 +1529,7 @@ { "cell_type": "code", "execution_count": null, - "id": "151", + "id": "149", "metadata": {}, "outputs": [], "source": [ @@ -1563,7 +1545,7 @@ }, { "cell_type": "markdown", - "id": "152", + "id": "150", "metadata": {}, "source": [ "Set more constraints." @@ -1572,7 +1554,7 @@ { "cell_type": "code", "execution_count": null, - "id": "153", + "id": "151", "metadata": {}, "outputs": [], "source": [ @@ -1583,7 +1565,7 @@ }, { "cell_type": "markdown", - "id": "154", + "id": "152", "metadata": {}, "source": [ "Show defined constraints." @@ -1592,7 +1574,7 @@ { "cell_type": "code", "execution_count": null, - "id": "155", + "id": "153", "metadata": { "lines_to_next_cell": 2 }, @@ -1603,7 +1585,7 @@ }, { "cell_type": "markdown", - "id": "156", + "id": "154", "metadata": {}, "source": [ "Set structure parameters to be refined." @@ -1612,7 +1594,7 @@ { "cell_type": "code", "execution_count": null, - "id": "157", + "id": "155", "metadata": {}, "outputs": [], "source": [ @@ -1621,7 +1603,7 @@ }, { "cell_type": "markdown", - "id": "158", + "id": "156", "metadata": {}, "source": [ "Show free parameters after selection." @@ -1630,7 +1612,7 @@ { "cell_type": "code", "execution_count": null, - "id": "159", + "id": "157", "metadata": {}, "outputs": [], "source": [ @@ -1639,7 +1621,7 @@ }, { "cell_type": "markdown", - "id": "160", + "id": "158", "metadata": {}, "source": [ "#### Run Fitting" @@ -1648,7 +1630,7 @@ { "cell_type": "code", "execution_count": null, - "id": "161", + "id": "159", "metadata": {}, "outputs": [], "source": [ @@ -1659,7 +1641,7 @@ }, { "cell_type": "markdown", - "id": "162", + "id": "160", "metadata": {}, "source": [ "#### Display Pattern" @@ -1668,7 +1650,7 @@ { "cell_type": "code", "execution_count": null, - "id": "163", + "id": "161", "metadata": {}, "outputs": [], "source": [ @@ -1678,26 +1660,16 @@ { "cell_type": "code", "execution_count": null, - "id": "164", + "id": "162", "metadata": {}, "outputs": [], "source": [ "project.display.pattern(expt_name='hrpt', x_min=38, x_max=41)" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "165", - "metadata": {}, - "outputs": [], - "source": [ - "project.display.structure(struct_name='lbco')" - ] - }, { "cell_type": "markdown", - "id": "166", + "id": "163", "metadata": {}, "source": [ "#### Display Structure" @@ -1706,7 +1678,7 @@ { "cell_type": "code", "execution_count": null, - "id": "167", + "id": "164", "metadata": {}, "outputs": [], "source": [ @@ -1715,7 +1687,7 @@ }, { "cell_type": "markdown", - "id": "168", + "id": "165", "metadata": {}, "source": [ "## 📊 Report\n", @@ -1735,7 +1707,7 @@ { "cell_type": "code", "execution_count": null, - "id": "169", + "id": "166", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/docs/tutorials/ed-3.py b/docs/docs/tutorials/ed-3.py index 6b1c06dc1..16af51f1c 100644 --- a/docs/docs/tutorials/ed-3.py +++ b/docs/docs/tutorials/ed-3.py @@ -58,7 +58,7 @@ # directory path. # %% -project.save_as(dir_path='projects/lbco_hrpt') +project.save_as(dir_path='projects/ed_3_lbco_hrpt') # %% [markdown] # ## 🧩 Define Structure @@ -257,7 +257,7 @@ # ### Show Measured Data # %% -project.display.pattern(expt_name='hrpt', include='measured') +project.display.pattern(expt_name='hrpt') # %% [markdown] # ### Set Instrument @@ -380,12 +380,6 @@ # %% project.rendering_plot.help() -# %% [markdown] -# ### Show Calculated Data - -# %% -project.display.pattern(expt_name='hrpt', include='calculated') - # %% [markdown] # ### Display Pattern @@ -675,9 +669,6 @@ # %% project.display.pattern(expt_name='hrpt', x_min=38, x_max=41) -# %% -project.display.structure(struct_name='lbco') - # %% [markdown] # #### Display Structure diff --git a/docs/docs/tutorials/ed-4.ipynb b/docs/docs/tutorials/ed-4.ipynb index c45ed29cf..8f7797640 100644 --- a/docs/docs/tutorials/ed-4.ipynb +++ b/docs/docs/tutorials/ed-4.ipynb @@ -511,7 +511,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='pbso4_joint')" ] }, { @@ -712,6 +712,24 @@ "source": [ "project.display.pattern(expt_name='xrd', x_min=29.0, x_max=30.4)" ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_4_pbso4_joint')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-4.py b/docs/docs/tutorials/ed-4.py index 0e974893a..1ab7a4416 100644 --- a/docs/docs/tutorials/ed-4.py +++ b/docs/docs/tutorials/ed-4.py @@ -235,7 +235,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='pbso4_joint') # %% [markdown] # ### Add Structure @@ -320,3 +320,9 @@ # %% project.display.pattern(expt_name='xrd', x_min=29.0, x_max=30.4) + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_4_pbso4_joint') diff --git a/docs/docs/tutorials/ed-5.ipynb b/docs/docs/tutorials/ed-5.ipynb index 391ca3651..a57352ba9 100644 --- a/docs/docs/tutorials/ed-5.ipynb +++ b/docs/docs/tutorials/ed-5.ipynb @@ -361,7 +361,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='cosio_d20')" ] }, { @@ -371,7 +371,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.save_as('projects/cosio_d20')" + "project.save_as(dir_path='projects/ed_5_cosio_d20')" ] }, { diff --git a/docs/docs/tutorials/ed-5.py b/docs/docs/tutorials/ed-5.py index 942444d25..23a091d6e 100644 --- a/docs/docs/tutorials/ed-5.py +++ b/docs/docs/tutorials/ed-5.py @@ -171,10 +171,10 @@ # ### Create Project # %% -project = Project() +project = Project(name='cosio_d20') # %% -project.save_as('projects/cosio_d20') +project.save_as(dir_path='projects/ed_5_cosio_d20') # %% [markdown] # ### Add Structure diff --git a/docs/docs/tutorials/ed-6.ipynb b/docs/docs/tutorials/ed-6.ipynb index 561afb750..e38f74ef4 100644 --- a/docs/docs/tutorials/ed-6.ipynb +++ b/docs/docs/tutorials/ed-6.ipynb @@ -320,7 +320,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='hs_hrpt')" ] }, { @@ -830,6 +830,24 @@ "The HTML report is written automatically when the project is saved;\n", "enable `project.report.pdf` as well for a PDF version." ] + }, + { + "cell_type": "markdown", + "id": "77", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_6_hs_hrpt')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-6.py b/docs/docs/tutorials/ed-6.py index f2968b774..50a3ab363 100644 --- a/docs/docs/tutorials/ed-6.py +++ b/docs/docs/tutorials/ed-6.py @@ -150,7 +150,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='hs_hrpt') # %% [markdown] # ### Add Structure @@ -344,3 +344,9 @@ # # The HTML report is written automatically when the project is saved; # enable `project.report.pdf` as well for a PDF version. + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_6_hs_hrpt') diff --git a/docs/docs/tutorials/ed-7.ipynb b/docs/docs/tutorials/ed-7.ipynb index e0902a8e7..5f5ae0b00 100644 --- a/docs/docs/tutorials/ed-7.ipynb +++ b/docs/docs/tutorials/ed-7.ipynb @@ -286,7 +286,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='si_sepd')" ] }, { @@ -926,6 +926,24 @@ "source": [ "project.display.pattern(expt_name='sepd', x='d_spacing')" ] + }, + { + "cell_type": "markdown", + "id": "91", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_7_si_sepd')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-7.py b/docs/docs/tutorials/ed-7.py index a8d568805..09e51da4f 100644 --- a/docs/docs/tutorials/ed-7.py +++ b/docs/docs/tutorials/ed-7.py @@ -117,7 +117,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='si_sepd') # %% [markdown] # ### Add Structure @@ -356,3 +356,9 @@ # %% project.display.pattern(expt_name='sepd', x='d_spacing') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_7_si_sepd') diff --git a/docs/docs/tutorials/ed-8.ipynb b/docs/docs/tutorials/ed-8.ipynb index 7e02e5770..097ac6f57 100644 --- a/docs/docs/tutorials/ed-8.ipynb +++ b/docs/docs/tutorials/ed-8.ipynb @@ -498,7 +498,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='ncaf_wish')" ] }, { @@ -713,6 +713,24 @@ "The HTML report is written automatically when the project is saved;\n", "enable `project.report.pdf` as well for a PDF version." ] + }, + { + "cell_type": "markdown", + "id": "55", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_8_ncaf_wish')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-8.py b/docs/docs/tutorials/ed-8.py index d875c3998..94c0a89d5 100644 --- a/docs/docs/tutorials/ed-8.py +++ b/docs/docs/tutorials/ed-8.py @@ -268,7 +268,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='ncaf_wish') # %% [markdown] # ### Add Structure @@ -360,3 +360,9 @@ # # The HTML report is written automatically when the project is saved; # enable `project.report.pdf` as well for a PDF version. + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_8_ncaf_wish') diff --git a/docs/docs/tutorials/ed-9.ipynb b/docs/docs/tutorials/ed-9.ipynb index 8d1ecdfee..613268b88 100644 --- a/docs/docs/tutorials/ed-9.ipynb +++ b/docs/docs/tutorials/ed-9.ipynb @@ -429,7 +429,7 @@ "metadata": {}, "outputs": [], "source": [ - "project = Project()" + "project = Project(name='lbco_si_mcstas')" ] }, { @@ -523,7 +523,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.display.pattern(expt_name='mcstas', include='measured')" + "project.display.pattern(expt_name='mcstas')" ] }, { @@ -578,7 +578,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.display.pattern(expt_name='mcstas', include=('measured', 'excluded'))" + "project.display.pattern(expt_name='mcstas')" ] }, { @@ -695,6 +695,24 @@ "source": [ "project.display.pattern(expt_name='mcstas')" ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "## 💾 Save Project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "project.save_as(dir_path='projects/ed_9_lbco_si_mcstas')" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-9.py b/docs/docs/tutorials/ed-9.py index 4fe451af2..a869b2583 100644 --- a/docs/docs/tutorials/ed-9.py +++ b/docs/docs/tutorials/ed-9.py @@ -195,7 +195,7 @@ # ### Create Project # %% -project = Project() +project = Project(name='lbco_si_mcstas') # %% [markdown] # ### Add Structures @@ -229,7 +229,7 @@ # Show measured data as loaded from the file. # %% -project.display.pattern(expt_name='mcstas', include='measured') +project.display.pattern(expt_name='mcstas') # %% [markdown] # Add excluded regions. @@ -248,7 +248,7 @@ # Show measured data after adding excluded regions. # %% -project.display.pattern(expt_name='mcstas', include=('measured', 'excluded')) +project.display.pattern(expt_name='mcstas') # %% [markdown] # Show experiment as CIF. @@ -304,3 +304,9 @@ # %% project.display.pattern(expt_name='mcstas') + +# %% [markdown] +# ## 💾 Save Project + +# %% +project.save_as(dir_path='projects/ed_9_lbco_si_mcstas') diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8dba57ab1..b7b7786fd 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -161,7 +161,18 @@ plugins: show_root_heading: true show_root_full_path: false show_submodules: true - show_source: true + show_source: false + show_bases: false + # Uncomment when the project depends on `easyscience` and inherited + # EasyScience base-class members should be shown in the API docs. + # preload_modules: + # - easyscience + inherited_members: true + show_category_heading: true + merge_init_into_class: true + docstring_options: + ignore_init_summary: true + summary: true - search # Determines additional directories to watch when running mkdocs serve diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 07acc6194..e0010f85f 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -37,20 +37,24 @@ {# Download link: relative to the current page #} {% set file_url = filename %} - {# Open in Colab (absolute GitHub URL; works anywhere) #} - - {% include ".icons/google-colab.svg" %} - - - {# Download: use a RELATIVE link to the file next to this page #} - - {% include ".icons/material/download.svg" %} - + {# Action buttons as a left-aligned row above the notebook title; see + .md-content__nb-actions in extra.css #} +

+ {# Open in Colab (absolute GitHub URL; works anywhere) #} + + {% include ".icons/google-colab.svg" %} + + + {# Download: use a RELATIVE link to the file next to this page #} + + {% include ".icons/material/download.svg" %} + +
{% endif %} {{ super() }} diff --git a/pixi.lock b/pixi.lock index 96e17870c..d4ab13b04 100644 --- a/pixi.lock +++ b/pixi.lock @@ -274,7 +274,6 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -598,7 +597,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -915,7 +913,6 @@ environments: - 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/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -1259,7 +1256,6 @@ environments: - 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/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -1581,7 +1577,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -1899,7 +1894,6 @@ environments: - 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/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -2239,7 +2233,6 @@ environments: - 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/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -2563,7 +2556,6 @@ environments: - 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/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -2880,7 +2872,6 @@ environments: - 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/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/8e/5a/7fd1b784a87e96e0078f49a0a13a98b4c5f644ba5597a4a3b70a2ba3e613/py3dmol-2.5.5-py2.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/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl @@ -8763,10 +8754,9 @@ packages: - lmfit - numpy - pandas - - pillow>=10.1 + - pillow - plotly - pooch - - py3dmol - rich - scipy - sympy diff --git a/pixi.toml b/pixi.toml index bb0f0b629..928c8bb22 100644 --- a/pixi.toml +++ b/pixi.toml @@ -106,8 +106,30 @@ 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' -script-tests = { cmd = 'python -m pytest tools/test_scripts.py --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } -notebook-tests = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } + +# Remove previously saved tutorial output projects (projects/ed_*) so the +# tutorial-output checks cannot pass against a stale artifact from an earlier +# run. Downloaded input projects (projects/ed-NN) are preserved. +clean-tutorial-projects = { cmd = 'python tools/clean_tutorial_projects.py', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } +script-tests = { cmd = 'python -m pytest tools/test_scripts.py --color=yes -n auto -v', depends-on = [ + 'clean-tutorial-projects', +], env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } +notebook-tests = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v', depends-on = [ + 'clean-tutorial-projects', +], env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } + +# Parse the analysis.cif of every saved tutorial project and assert its +# refined fit results against tests/tutorials/baseline.json. Run only after +# the tutorials have been executed (script-tests or notebook-tests), so the +# saved projects exist under tmp/tutorials/projects/. +tutorial-output-tests = { cmd = 'python -m pytest tests/tutorials/ --color=yes -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } +# Run the tutorials and then verify their saved outputs in a single step. +script-tests-checked = { cmd = 'python -m pytest tests/tutorials/ --color=yes -v', depends-on = [ + 'script-tests', +], env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } +notebook-tests-checked = { cmd = 'python -m pytest tests/tutorials/ --color=yes -v', depends-on = [ + 'notebook-tests', +], env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } test = { depends-on = ['unit-tests', 'functional-tests'] } test-all = { depends-on = [ diff --git a/pyproject.toml b/pyproject.toml index c8d57b976..6e5b27250 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,7 @@ dependencies = [ 'darkdetect', # Detecting dark mode (system-level) 'pandas', # Displaying tables in Jupyter notebooks 'plotly', # Interactive plots - 'py3Dmol', # Visualisation of crystal structures - 'pillow>=10.1', # Rendering structure figures (labels, legend) for reports + 'pillow', # Rendering structure figures (labels, legend) for reports ] [project.optional-dependencies] @@ -225,7 +224,9 @@ exclude = [ 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 - +builtins = [ + 'display', +] # Clutch-fix/patch to https://github.com/nbQA-dev/nbQA/issues/882 # Formatting options for Ruff [tool.ruff.format] @@ -358,6 +359,9 @@ ignore = [ 'D', 'W', ] +'docs/docs/tutorials/**' = [ + 'E402', # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ +] # Specific options for certain rules diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 41ffc5443..38afe0f39 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -51,9 +51,8 @@ from easydiffraction.core.category_owner import CategoryOwner from easydiffraction.core.guard import _apply_help_filter from easydiffraction.core.singleton import ConstraintsHandler -from easydiffraction.core.variable import NumericDescriptor +from easydiffraction.core.variable import GenericNumericDescriptor from easydiffraction.core.variable import Parameter -from easydiffraction.core.variable import StringDescriptor from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.display.progress import make_display_handle @@ -75,8 +74,17 @@ from easydiffraction.analysis.categories.fit_result import FitResultBase from easydiffraction.analysis.categories.minimizer.base import MinimizerCategoryBase from easydiffraction.core.posterior import PosteriorParameterSummary - -_SUMMARY_HIDDEN_PARAMETER_CATEGORIES = frozenset({'pd_data', 'total_data', 'refln'}) + from easydiffraction.core.variable import GenericDescriptorBase + +# Categories hidden from the parameter summary tables: bulky measured +# data and derived, read-only tables that would only add noise. The +# space_group_Wyckoff table also carries unreadably long coords_xyz. +_SUMMARY_HIDDEN_PARAMETER_CATEGORIES = frozenset({ + 'pd_data', + 'total_data', + 'refln', + 'space_group_Wyckoff', +}) _POSTERIOR_SAMPLE_NDIM = 3 @@ -175,8 +183,8 @@ def _flush_structure_categories(self) -> None: @staticmethod def _summary_parameters( - params: list[StringDescriptor | NumericDescriptor | Parameter], - ) -> list[StringDescriptor | NumericDescriptor | Parameter]: + params: list[GenericDescriptorBase], + ) -> list[GenericDescriptorBase]: """Return parameters suitable for compact summary displays.""" return [ param @@ -328,25 +336,23 @@ def how_to_access_parameters(self) -> None: project_varname = project._varname for datablock_code, params in all_params.items(): for param in params: - if isinstance(param, (StringDescriptor, NumericDescriptor, Parameter)): - datablock_entry_name = param._identity.datablock_entry_name - category_code = param._identity.category_code - category_entry_name = param._identity.category_entry_name or '' - param_key = param.name - code_variable = ( - f'{project_varname}.{datablock_code}' - f"['{datablock_entry_name}'].{category_code}" - ) - if category_entry_name: - code_variable += f"['{category_entry_name}']" - code_variable += f'.{param_key}' - columns_data.append([ - datablock_entry_name, - category_code, - category_entry_name, - param_key, - code_variable, - ]) + datablock_entry_name = param._identity.datablock_entry_name + category_code = param._identity.category_code + category_entry_name = param._identity.category_entry_name or '' + param_key = param.name + code_variable = ( + f"{project_varname}.{datablock_code}['{datablock_entry_name}'].{category_code}" + ) + if category_entry_name: + code_variable += f"['{category_entry_name}']" + code_variable += f'.{param_key}' + columns_data.append([ + datablock_entry_name, + category_code, + category_entry_name, + param_key, + code_variable, + ]) console.paragraph('How to access parameters') render_table( @@ -393,19 +399,18 @@ def parameter_cif_uids(self) -> None: columns_data = [] for params in all_params.values(): for param in params: - if isinstance(param, (StringDescriptor, NumericDescriptor, Parameter)): - datablock_entry_name = param._identity.datablock_entry_name - category_code = param._identity.category_code - category_entry_name = param._identity.category_entry_name or '' - param_key = param.name - cif_uid = param._cif_handler.uid - columns_data.append([ - datablock_entry_name, - category_code, - category_entry_name, - param_key, - cif_uid, - ]) + datablock_entry_name = param._identity.datablock_entry_name + category_code = param._identity.category_code + category_entry_name = param._identity.category_entry_name or '' + param_key = param.name + cif_uid = param._cif_handler.uid + columns_data.append([ + datablock_entry_name, + category_code, + category_entry_name, + param_key, + cif_uid, + ]) console.paragraph('Show parameter CIF unique identifiers') render_table( @@ -1200,15 +1205,15 @@ def _has_software_provenance(self) -> bool: @staticmethod def _get_params_as_dataframe( - params: list[NumericDescriptor | Parameter], + params: list[GenericDescriptorBase], ) -> pd.DataFrame: """ Convert a list of parameters to a DataFrame. Parameters ---------- - params : list[NumericDescriptor | Parameter] - List of DescriptorFloat or Parameter objects. + params : list[GenericDescriptorBase] + List of descriptor or parameter objects. Returns ------- @@ -1217,19 +1222,17 @@ def _get_params_as_dataframe( """ records = [] for param in params: - record = {} # TODO: Merge into one. Add field if attr exists # TODO: f'{param.value!r}' for StringDescriptor? - if isinstance(param, (StringDescriptor, NumericDescriptor, Parameter)): - record = { - ('fittable', 'left'): False, - ('datablock', 'left'): param._identity.datablock_entry_name, - ('category', 'left'): param._identity.category_code, - ('entry', 'left'): param._identity.category_entry_name or '', - ('parameter', 'left'): param.name, - ('value', 'right'): param.value, - } - if isinstance(param, (NumericDescriptor, Parameter)): + record = { + ('fittable', 'left'): False, + ('datablock', 'left'): param._identity.datablock_entry_name, + ('category', 'left'): param._identity.category_code, + ('entry', 'left'): param._identity.category_entry_name or '', + ('parameter', 'left'): param.name, + ('value', 'right'): '' if param.value is None else param.value, + } + if isinstance(param, GenericNumericDescriptor): record |= { ('units', 'left'): _parameter_display_units(param), } diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index 16fcd818b..fdd4bda46 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -140,6 +140,11 @@ def calculate_structure_factors( cryspy_in_out_dict: dict[str, Any] = {} + # TODO: This is temporary solution to mark all structures as + # nuclear-only. Once magnetic structure is implemented, we + # would need to auto-detect it. + cryspy_dict[f'crystal_{structure.name}']['flag_only_nuclear'] = True + # Calculate the pattern using Cryspy # TODO: Redirect stderr to suppress Cryspy warnings. # This is a temporary solution to avoid cluttering the output. @@ -223,6 +228,11 @@ def calculate_pattern( cryspy_in_out_dict: dict[str, Any] = {} + # TODO: This is temporary solution to mark all structures as + # nuclear-only. Once magnetic structure is implemented, we + # would need to auto-detect it. + cryspy_dict[f'crystal_{structure.name}']['flag_only_nuclear'] = True + # Calculate the pattern using Cryspy # TODO: Redirect stderr to suppress Cryspy warnings. # This is a temporary solution to avoid cluttering the output. diff --git a/src/easydiffraction/core/variable.py b/src/easydiffraction/core/variable.py index 03dac6b1f..8bcffb872 100644 --- a/src/easydiffraction/core/variable.py +++ b/src/easydiffraction/core/variable.py @@ -329,8 +329,9 @@ def __str__(self) -> str: """Return the string representation including units.""" s: str = super().__str__() s = s[1:-1] # strip <> - if self.units != 'none': - s += f' {self.units}' + units = self.resolve_display_units('gui') + if units: + s += f' {units}' return f'<{s}>' @property @@ -412,8 +413,9 @@ def __str__(self) -> str: s = s[1:-1] # strip <> if self.uncertainty is not None: s += f' ± {self.uncertainty}' - if self.units != 'none': - s += f' {self.units}' + units = self.resolve_display_units('gui') + if units: + s += f' {units}' s += f' (free={self.free})' return f'<{s}>' diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py index d540d6bb2..0ebd66b16 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py @@ -23,7 +23,12 @@ class TofGaussianBroadeningMixin: - """TOF Gaussian broadening parameters σ₀, σ₁, σ₂.""" + """ + TOF Gaussian broadening parameters σ₀, σ₁, σ₂. + + The constant term σ₀ defaults nonzero so every TOF profile has a + finite peak width out of the box; σ₁ and σ₂ default to 0. + """ def __init__(self) -> None: super().__init__() @@ -37,7 +42,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}^2$', ), value_spec=AttributeSpec( - default=0.0, + default=7.0, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -230,6 +235,9 @@ class TofBackToBackExponentialMixin: Rise parameters α₀, α₁ and decay parameters β₀, β₁ follow Von Dreele, Jorgensen & Windsor, J. Appl. Cryst. 15, 581 (1982). + + The rise α₁ and decay β₀ default nonzero so the profile is + normalisable and the peak is visible; refine per instrument. """ def __init__(self) -> None: @@ -244,7 +252,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}$', ), value_spec=AttributeSpec( - default=0.01, + default=0.0, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -261,7 +269,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}/\mathrm{\AA}$', ), value_spec=AttributeSpec( - default=0.02, + default=0.2, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -278,7 +286,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}$', ), value_spec=AttributeSpec( - default=0.0, + default=0.04, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -368,6 +376,9 @@ class TofDoubleExponentialMixin: Rise parameters α₁, α₂, decay parameters β₀₀, β₀₁, β₁₀ for two exponential regimes, and switching-function parameters r₀₁, r₀₂, r₀₃. + + α₁, β₀₀, β₁₀ and r₀₁ default nonzero so both regimes stay finite and + blended; an all-zero set produces NaN. Refine per instrument. """ def __init__(self) -> None: @@ -382,7 +393,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}$', ), value_spec=AttributeSpec( - default=0.0, + default=0.25, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -416,7 +427,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}$', ), value_spec=AttributeSpec( - default=0.0, + default=4.0, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -450,7 +461,7 @@ def __init__(self) -> None: latex_units=r'$\mu\mathrm{s}$', ), value_spec=AttributeSpec( - default=0.0, + default=2.0, validator=RangeValidator(), ), cif_handler=CifHandler( @@ -463,7 +474,7 @@ def __init__(self) -> None: description='Double-exp switching function r₀₁', units='none', value_spec=AttributeSpec( - default=0.0, + default=0.5, validator=RangeValidator(), ), cif_handler=CifHandler( diff --git a/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py index 3c6256567..0478cfe02 100644 --- a/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py +++ b/src/easydiffraction/datablocks/structure/categories/space_group_wyckoff/default.py @@ -125,6 +125,26 @@ def __init__(self) -> None: """Initialise an empty derived Wyckoff collection.""" super().__init__(item_type=SpaceGroupWyckoff) + @staticmethod + def _skip_cif_serialization() -> bool: + """ + Suppress all serialized output for this derived category. + + The Wyckoff table is rebuilt from the structure's space group on + every update, so it is code-only: reachable via + ``structure.space_group_wyckoff`` but never written to project + CIF, the IUCr export, or HTML/TeX reports. Every serialization + path that consults this hook (``category_collection_to_cif`` and + the report data context) honours the suppression, so no + owner-level category filtering is required. + + Returns + ------- + bool + Always ``True``. + """ + return True + @override def add(self, item: object) -> None: """ diff --git a/src/easydiffraction/datablocks/structure/item/base.py b/src/easydiffraction/datablocks/structure/item/base.py index 704a98c9c..addc8552d 100644 --- a/src/easydiffraction/datablocks/structure/item/base.py +++ b/src/easydiffraction/datablocks/structure/item/base.py @@ -253,16 +253,6 @@ def _update_categories( self._need_categories_update = False - def _serializable_categories(self) -> list: - """ - Project-CIF categories (excludes the derived Wyckoff table). - """ - return [ - category - for category in self.categories - if not isinstance(category, SpaceGroupWyckoffCollection) - ] - # ------------------------------------------------------------------ # Public methods # ------------------------------------------------------------------ diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py index 5a02c37b8..5e2d38eca 100644 --- a/src/easydiffraction/display/plotters/base.py +++ b/src/easydiffraction/display/plotters/base.py @@ -16,6 +16,9 @@ from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum DEFAULT_HEIGHT = 25 +# Residual-to-main row height ratio shared by the composite figure and +# the single-panel figure so both derive the same main-panel height. +DEFAULT_RESIDUAL_HEIGHT_FRACTION = 0.25 DEFAULT_MIN = -np.inf DEFAULT_MAX = np.inf diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index 8bfed31a4..a20a8dd82 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -30,6 +30,7 @@ HTML = None from easydiffraction.display.plotters.base import DEFAULT_HEIGHT +from easydiffraction.display.plotters.base import DEFAULT_RESIDUAL_HEIGHT_FRACTION from easydiffraction.display.plotters.base import SERIES_CONFIG from easydiffraction.display.plotters.base import BraggTickSet from easydiffraction.display.plotters.base import PlotterBase @@ -1998,6 +1999,7 @@ def _get_layout( *, axis_range: tuple[float, float] | None = None, axis_dtick: float | None = None, + height: int | None = None, ) -> object: """ Create a Plotly layout configuration. @@ -2015,6 +2017,8 @@ def _get_layout( axis_dtick : float | None, default=None When given, the same tick step applied to both axes, so the x and y ticks match. + height : int | None, default=None + Explicit figure height in pixels; ``None`` auto-sizes. Returns ------- @@ -2076,6 +2080,7 @@ def _get_layout( 'yanchor': 'top', 'y': 0.99, }, + height=height, xaxis=xaxis, yaxis=yaxis, shapes=shapes, @@ -2114,7 +2119,8 @@ def plot_powder( excluded_ranges : tuple[tuple[float, float], ...], default=() Excluded x-ranges to shade on the figure. """ - # Intentionally unused; accepted for API compatibility + # The passed height is an ASCII row count; the Plotly single + # panel is sized to the composite main row below instead. del height data = [] @@ -2123,12 +2129,22 @@ def plot_powder( trace = self._get_powder_trace(x, y, label) data.append(trace) + # Share the composite's sizing and range primitives so a single + # panel is its main row by construction: the same explicit + # height (otherwise the docs skeleton falls back to the full + # three-panel height) and the same tight x-range with no + # autoscale padding. ``_get_layout`` already uses the composite + # margins, so the drawable area matches pixel-for-pixel. layout = self._get_layout( title, axes_labels, + height=self._single_main_panel_height_pixels(DEFAULT_RESIDUAL_HEIGHT_FRACTION), ) fig = self._get_figure(data, layout) + x_min, x_max = self._composite_x_range(np.asarray(x)) + if x_min is not None and x_max is not None: + fig.update_xaxes(range=[x_min, x_max]) self._add_excluded_region_vrects(fig=fig, excluded_ranges=excluded_ranges) self._show_figure(fig) @@ -2978,7 +2994,10 @@ def plot_scatter( height: int | None = None, ) -> None: """Render a scatter plot with error bars via Plotly.""" - _ = height # not used by Plotly backend + # The passed height is an ASCII row count; the Plotly scatter + # panel is sized to the composite main row instead, so it + # matches the pattern plot's top panel. + del height trace = go.Scatter( x=x, @@ -3005,6 +3024,7 @@ def plot_scatter( layout = self._get_layout( title, axes_labels, + height=self._single_main_panel_height_pixels(DEFAULT_RESIDUAL_HEIGHT_FRACTION), ) fig = self._get_figure(trace, layout) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 90b0c8aca..2688711e9 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -30,6 +30,7 @@ from easydiffraction.display.plotters.base import DEFAULT_HEIGHT from easydiffraction.display.plotters.base import DEFAULT_MAX from easydiffraction.display.plotters.base import DEFAULT_MIN +from easydiffraction.display.plotters.base import DEFAULT_RESIDUAL_HEIGHT_FRACTION from easydiffraction.display.plotters.base import DEFAULT_X_AXIS from easydiffraction.display.plotters.base import BraggTickSet from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec @@ -82,7 +83,6 @@ class PosteriorPairPlotStyleEnum(StrEnum): DEFAULT_CORRELATION_THRESHOLD: float | None = None DEFAULT_CORRELATION_MAX_PARAMETERS = 6 EXPECTED_COVAR_NDIM = 2 -DEFAULT_RESIDUAL_HEIGHT_FRACTION = 0.25 DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION = 0.10 DEFAULT_RESID_HEIGHT = DEFAULT_RESIDUAL_HEIGHT_FRACTION DEFAULT_BRAGG_ROW = DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION @@ -142,7 +142,7 @@ class PosteriorPairPlotStyleEnum(StrEnum): [0.82, 'rgba(215, 48, 39, 0.98)'], [1.0, 'rgba(215, 48, 39, 0.98)'], ] -POSTERIOR_PAIR_SCATTER_MAX_POINTS = 1500 +POSTERIOR_PAIR_SCATTER_MAX_POINTS = 750 # keep embedded pair scatter small POSTERIOR_PAIR_MAX_DENSITY_SAMPLES = 4000 POSTERIOR_PAIR_MIN_DENSITY_SAMPLES = 800 POSTERIOR_PAIR_TARGET_DENSITY_SAMPLE_BUDGET = 24000 @@ -177,7 +177,6 @@ class PosteriorPairPlotStyleEnum(StrEnum): CORRELATION_CELL_LABEL_CHAR_COUNT = 16 CORRELATION_LABEL_CHAR_WIDTH_FACTOR = 0.6 POSTERIOR_PAIR_SAMPLE_MARKER_SIZE = 6 -POSTERIOR_PAIR_SAMPLE_HOVER_MARKER_SIZE = 6 @dataclass(frozen=True) @@ -2096,7 +2095,7 @@ def _add_posterior_pair_off_diagonal( name='Posterior samples', legendgroup='posterior-samples', showlegend=legend_state.show_scatter, - hoverinfo='skip', + hovertemplate=sample_hovertemplate, zorder=0, ), row=row, @@ -2112,22 +2111,6 @@ def _add_posterior_pair_off_diagonal( fig.add_trace(contour_traces[0], row=row, col=col) fig.add_trace(contour_traces[1], row=row, col=col) legend_state.show_contour = False - fig.add_trace( - go.Scatter( - x=x_scatter_values, - y=y_scatter_values, - mode='markers', - marker={ - 'color': 'rgba(0, 0, 0, 0)', - 'size': POSTERIOR_PAIR_SAMPLE_HOVER_MARKER_SIZE, - }, - showlegend=False, - hovertemplate=sample_hovertemplate, - zorder=3, - ), - row=row, - col=col, - ) @staticmethod def _configure_posterior_pair_panel_axes( @@ -3043,29 +3026,46 @@ def _add_posterior_distribution_histogram( histogram_bin_edges: np.ndarray | None, ) -> None: """Add the histogram trace for a posterior distribution plot.""" - histogram_kwargs: dict[str, object] = {} - if ( - histogram_bin_edges is not None - and histogram_bin_edges.size >= MIN_POSTERIOR_SAMPLE_COUNT - ): - histogram_kwargs['xbins'] = { - 'start': float(histogram_bin_edges[0]), - 'end': float(histogram_bin_edges[-1]), - 'size': float(histogram_bin_edges[1] - histogram_bin_edges[0]), - } + marker = { + 'color': POSTERIOR_HISTOGRAM_FILL_COLOR, + 'line': {'color': POSTERIOR_HISTOGRAM_LINE_COLOR, 'width': 1}, + } + densities = Plotter._posterior_distribution_histogram_density( + values, + histogram_bin_edges, + ) + if densities is None or histogram_bin_edges is None: + # Degenerate sample (no usable bins): let Plotly bin the few + # raw values client-side; the embedded payload stays tiny. + fig.add_trace( + go.Histogram( + x=values, + histnorm='probability density', + marker=marker, + opacity=0.82, + name='Posterior histogram', + hovertemplate='sample=%{x:.4f}
density: %{y:.2f}', + ) + ) + return + # Pre-bin server-side and emit a Bar trace so only the per-bin + # densities ride in the page, not every raw posterior sample. + # ``go.Histogram(x=values)`` serializes the full sample array + # (hundreds of thousands of values per parameter), bloating the + # docs page and stalling the "Loading plot…" skeleton paint. + edges = np.asarray(histogram_bin_edges, dtype=float) + bin_centers = (edges[:-1] + edges[1:]) / 2.0 + bin_widths = np.diff(edges) fig.add_trace( - go.Histogram( - x=values, - histnorm='probability density', - marker={ - 'color': POSTERIOR_HISTOGRAM_FILL_COLOR, - 'line': {'color': POSTERIOR_HISTOGRAM_LINE_COLOR, 'width': 1}, - }, + go.Bar( + x=bin_centers, + y=densities, + width=bin_widths, + marker=marker, opacity=0.82, name='Posterior histogram', hovertemplate='sample=%{x:.4f}
density: %{y:.2f}', - **histogram_kwargs, ) ) diff --git a/src/easydiffraction/display/structure/templates/structure.html.j2 b/src/easydiffraction/display/structure/templates/structure.html.j2 index 51e8b9aa5..752cb018f 100644 --- a/src/easydiffraction/display/structure/templates/structure.html.j2 +++ b/src/easydiffraction/display/structure/templates/structure.html.j2 @@ -1,9 +1,9 @@ -
-
Loading 3D view…
+
Loading plot…
diff --git a/src/easydiffraction/display/tablers/pandas.py b/src/easydiffraction/display/tablers/pandas.py index 25a013902..38ff2590e 100644 --- a/src/easydiffraction/display/tablers/pandas.py +++ b/src/easydiffraction/display/tablers/pandas.py @@ -1,9 +1,14 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Pandas-based table renderer for notebooks using DataFrame Styler.""" +""" +Pandas-input table renderer emitting inline-styled HTML for notebooks. +""" from __future__ import annotations +import html +import re + try: from IPython.display import HTML from IPython.display import display @@ -11,225 +16,148 @@ HTML = None display = None -import re - from easydiffraction.display.tablers.base import TableBackendBase -from easydiffraction.display.theme import DARK_AXIS_FRAME_COLOR -from easydiffraction.display.theme import LIGHT_AXIS_FRAME_COLOR -from easydiffraction.display.theme import TABLE_AXIS_FRAME_CSS_VAR from easydiffraction.utils.environment import can_use_ipython_display from easydiffraction.utils.logging import log +# Rich-style inline colour markup, e.g. ``[red]text[/red]``. _RICH_COLOR_RE = re.compile(r'\[(\w+)\](.*?)\[/\1\]') -PANDAS_TABLE_THEME_CLASS = 'ed-themed-table' -PANDAS_AXIS_FRAME_COLOR = f'var({TABLE_AXIS_FRAME_CSS_VAR}, {LIGHT_AXIS_FRAME_COLOR})' + +# Theme-neutral translucent greys. Both read correctly on light and dark +# backgrounds, so the table needs no theme-sync script -- which matters +# because JupyterLab strips ``