diff --git a/.codecov.yml b/.codecov.yml index af816b1a2..97a636020 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -20,12 +20,16 @@ coverage: status: project: default: - target: auto + target: 80% threshold: 1% - # Make project coverage informational (won't block PR) - informational: true + # Project coverage is the real gate (see ADR + # test-suite-and-validation). + informational: false patch: default: target: auto - # Require patch coverage but with threshold threshold: 1% + # Non-blocking: patch grades diff lines against unit-only + # coverage, so engine/integration-tested code would otherwise + # always fail. See ADR test-suite-and-validation. + informational: true diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5208a3333..002bfc82f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,21 +26,8 @@ env: CI_BRANCH: ${{ github.head_ref || github.ref_name }} jobs: - # Job 1: Run docstring coverage - docstring-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 docstring coverage - run: pixi run docstring-coverage - - # Job 2: Run unit tests with coverage and upload to Codecov + # Job 1: Run unit tests with coverage and upload to Codecov. + # Docstring coverage is gated as a static check in lint-format.yml. unit-tests-coverage: runs-on: ubuntu-latest @@ -65,6 +52,6 @@ jobs: # Job 4: Build and publish dashboard (reusable workflow) run-reusable-workflows: - needs: [docstring-coverage, unit-tests-coverage] # depend on the previous jobs + needs: [unit-tests-coverage] # depend on the previous job uses: ./.github/workflows/dashboard.yml secrets: inherit diff --git a/.github/workflows/lint-format.yml b/.github/workflows/lint-format.yml index 16dd95c95..f1308cd7f 100644 --- a/.github/workflows/lint-format.yml +++ b/.github/workflows/lint-format.yml @@ -79,6 +79,12 @@ jobs: shell: bash run: pixi run docstring-lint-check + - name: Check docstring coverage (interrogate) + id: docstring_coverage + continue-on-error: true + shell: bash + run: pixi run docstring-coverage + - name: Check formatting of non-Python files (md, toml, etc.) id: nonpy_format continue-on-error: true @@ -91,6 +97,30 @@ jobs: shell: bash run: pixi run notebook-lint-check + - name: Check unit-test directory mirrors src/ structure + id: test_structure + continue-on-error: true + shell: bash + run: pixi run test-structure-check + + - name: Check spelling (codespell) + id: spell + continue-on-error: true + shell: bash + run: pixi run spell-check + + - name: Check documentation links (lychee) + id: link + continue-on-error: true + shell: bash + run: pixi run link-check + + - name: Build documentation strictly (no tutorial execution) + id: docs_build + continue-on-error: true + shell: bash + run: pixi run docs-build + # Add summary - name: Add quality checks summary if: always() @@ -106,8 +136,13 @@ jobs: echo "| py lint | ${{ steps.py_lint.outcome == 'success' && '✅' || '❌' }} |" echo "| py format | ${{ steps.py_format.outcome == 'success' && '✅' || '❌' }} |" echo "| docstring lint | ${{ steps.docstring_lint.outcome == 'success' && '✅' || '❌' }} |" + echo "| docstring cover | ${{ steps.docstring_coverage.outcome == 'success' && '✅' || '❌' }} |" echo "| nonpy format | ${{ steps.nonpy_format.outcome == 'success' && '✅' || '❌' }} |" echo "| notebooks lint | ${{ steps.notebook_lint.outcome == 'success' && '✅' || '❌' }} |" + echo "| test structure | ${{ steps.test_structure.outcome == 'success' && '✅' || '❌' }} |" + echo "| spelling | ${{ steps.spell.outcome == 'success' && '✅' || '❌' }} |" + echo "| doc links | ${{ steps.link.outcome == 'success' && '✅' || '❌' }} |" + echo "| docs strict build| ${{ steps.docs_build.outcome == 'success' && '✅' || '❌' }} |" } >> "$GITHUB_STEP_SUMMARY" # Fail job if any check failed @@ -118,7 +153,12 @@ jobs: || steps.py_lint.outcome == 'failure' || steps.py_format.outcome == 'failure' || steps.docstring_lint.outcome == 'failure' + || steps.docstring_coverage.outcome == 'failure' || steps.nonpy_format.outcome == 'failure' || steps.notebook_lint.outcome == 'failure' + || steps.test_structure.outcome == 'failure' + || steps.spell.outcome == 'failure' + || steps.link.outcome == 'failure' + || steps.docs_build.outcome == 'failure' shell: bash run: exit 1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..c8c5e85e0 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,45 @@ +# Nightly run of the FULL test suite across all cost tiers (default, pr, +# and nightly), plus the performance benchmarks. The per-push and per-PR +# runs in test.yml deselect the pr/nightly tiers; the nightly run does +# not. See ADR test-suite-and-validation. + +name: Nightly tests + +on: + # Run the nightly-tier tests on a daily schedule + schedule: + - cron: '0 3 * * *' # 03:00 UTC daily + # Allow manual runs from the Actions tab + workflow_dispatch: + +permissions: + contents: read + +# Allow only one concurrent nightly run per ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + nightly-tests: + runs-on: ubuntu-latest + + steps: + - name: Check-out repository + uses: actions/checkout@v6 + + - name: Set up pixi + uses: ./.github/actions/setup-pixi + + - name: Run the full test suite (all tiers) + run: pixi run test-all + + - name: Run performance benchmarks + run: pixi run benchmarks + + - name: Upload benchmark results + if: ${{ !cancelled() }} + uses: ./.github/actions/upload-artifact + with: + name: benchmark-results + path: benchmark.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86bfa6063..0f67fcf15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,17 +54,29 @@ jobs: outputs: pytest-marks: ${{ steps.set-mark.outputs.pytest_marks }} + run-pr-tier: ${{ steps.set-mark.outputs.run_pr_tier }} steps: - # Determine if integration tests should be run fully or only the fast ones - # (to save time on branches other than master and develop) - - name: Set mark for integration tests + # Select the test cost tier by branch (see ADR test-suite-and-validation): + # the pr tier runs on develop/master and pull requests; feature-branch + # pushes run only the default (fast) tier. The nightly tier runs + # separately in nightly.yml. + - name: Set test tier marker expression id: set-mark run: | - if [[ "${{ env.CI_BRANCH }}" == "master" || "${{ env.CI_BRANCH }}" == "develop" ]]; then - echo "pytest_marks=" >> $GITHUB_OUTPUT + # Pull requests and develop/master pushes run the pr tier; + # feature-branch pushes run only the default (fast) tier. On a + # pull_request event CI_BRANCH is the contributor branch, so the + # event type must be checked explicitly. run_pr_tier gates the + # integration steps: the integration layer is entirely pr-marked, + # so on the fast tier its selection is empty and pytest would exit + # 5 ("no tests collected"); skip the step in that case instead. + if [[ "${{ github.event_name }}" == "pull_request" || "${{ env.CI_BRANCH }}" == "master" || "${{ env.CI_BRANCH }}" == "develop" ]]; then + echo 'pytest_marks=-m "not nightly"' >> "$GITHUB_OUTPUT" + echo 'run_pr_tier=true' >> "$GITHUB_OUTPUT" else - echo "pytest_marks=-m fast" >> $GITHUB_OUTPUT + echo 'pytest_marks=-m "not pr and not nightly"' >> "$GITHUB_OUTPUT" + echo 'run_pr_tier=false' >> "$GITHUB_OUTPUT" fi # Job 2: Test code @@ -99,7 +111,7 @@ jobs: env="py-$(echo $py_ver | tr -d .)-env" # Converts 3.XX -> py-3XX-env echo "Running tests in environment: $env" - pixi run --environment $env unit-tests + pixi run --environment $env unit-tests ${{ needs.env-prepare.outputs.pytest-marks }} done - name: Run functional tests @@ -114,10 +126,14 @@ jobs: env="py-$(echo $py_ver | tr -d .)-env" # Converts 3.XX -> py-3XX-env echo "Running tests in environment: $env" - pixi run --environment $env functional-tests + pixi run --environment $env functional-tests ${{ needs.env-prepare.outputs.pytest-marks }} done + # Integration tests are entirely pr-marked, so they only run in the pr + # tier (PRs + develop/master). On feature-branch pushes the selection + # would be empty; skip the step rather than fail on pytest exit code 5. - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} + if: needs.env-prepare.outputs.run-pr-tier == 'true' shell: bash run: | set -euo pipefail @@ -173,7 +189,8 @@ jobs: # Job 3: Test the package package-test: - needs: source-test # depend on previous job + # env-prepare provides the tier marker expression; source-test the wheel + needs: [env-prepare, source-test] strategy: fail-fast: false @@ -270,7 +287,7 @@ jobs: cd easydiffraction_py$py_ver echo "Running tests" - pixi run python -m pytest ../tests/unit/ --color=yes -v + pixi run python -m pytest ../tests/unit/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} echo "Exiting pixi project directory" cd .. @@ -289,13 +306,15 @@ jobs: cd easydiffraction_py$py_ver echo "Running tests" - pixi run python -m pytest ../tests/functional/ --color=yes -v + pixi run python -m pytest ../tests/functional/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} echo "Exiting pixi project directory" cd .. done + # See the source-test job: integration tests run only in the pr tier. - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} + if: needs.env-prepare.outputs.run-pr-tier == 'true' shell: bash run: | set -euo pipefail diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 770dbca2f..6fc64e208 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,6 +39,13 @@ repos: pass_filenames: false stages: [manual] + - id: pixi-docstring-coverage + name: pixi run docstring-coverage + entry: pixi run docstring-coverage + language: system + pass_filenames: false + stages: [manual] + - id: pixi-nonpy-format-check name: pixi run nonpy-format-check entry: pixi run nonpy-format-check @@ -60,16 +67,16 @@ repos: pass_filenames: false stages: [manual] - - id: pixi-unit-tests - name: pixi run unit-tests - entry: pixi run unit-tests + - id: pixi-spell-check + name: pixi run spell-check + entry: pixi run spell-check language: system pass_filenames: false stages: [manual] - - id: pixi-functional-tests - name: pixi run functional-tests - entry: pixi run functional-tests + - id: pixi-link-check + name: pixi run link-check + entry: pixi run link-check language: system pass_filenames: false stages: [manual] diff --git a/docs/dev/adrs/accepted/test-strategy.md b/docs/dev/adrs/accepted/test-strategy.md index 17194c51c..d5e03939b 100644 --- a/docs/dev/adrs/accepted/test-strategy.md +++ b/docs/dev/adrs/accepted/test-strategy.md @@ -39,3 +39,12 @@ aliases. New features should add focused tests at the lowest useful layer and broader tests when behavior crosses module boundaries. The mirrored structure makes missing coverage easier to spot. + +## Amendments + +[Test Suite and Validation Strategy](test-suite-and-validation.md) +sharpens these layer definitions into strict, testable placement +criteria and adds test cost tiers, coverage policy, codecov +configuration, cross-engine verification documentation, and a nightly +validation harness. The practical placement rules live in the +[Testing Guide](../../testing-guide.md). diff --git a/docs/dev/adrs/accepted/test-suite-and-validation.md b/docs/dev/adrs/accepted/test-suite-and-validation.md new file mode 100644 index 000000000..b35aea302 --- /dev/null +++ b/docs/dev/adrs/accepted/test-suite-and-validation.md @@ -0,0 +1,463 @@ +# ADR: Test Suite and Validation Strategy + +## Status + +Accepted. + +## Date + +2026-06-05 + +## Group + +Quality. + +## Context + +EasyDiffraction now has five test layers — unit, functional, +integration, script, and notebook — plus a tutorial-output regression +check. The layers were established by +[Test Strategy](../accepted/test-strategy.md), which defines each in a +single line and states that the unit tree mirrors the source tree "where +practical." + +That high-level statement is no longer enough. Concrete problems have +accumulated: + +- **Placement is under-specified and already violated.** The only + discriminator between functional and integration is "without heavy + external dependencies" vs "real calculation engines and data," yet + functional tests perform real network `download_data()`. Some unit + tests are slow (parametrised sampler/plotting/display cases) and some + call `download_data()` (mocked, but undocumented). There is no written + rule an author can apply to a borderline test. +- **Codecov patch status is always red.** `.codecov.yml` runs a blocking + `patch: target: auto` against a **unit-only** coverage upload + (`coverage.yml` uploads only `coverage-unit.xml`). Any pull request + that touches code exercised mainly by functional, integration, or + script tests scores near-zero patch coverage and fails the blocking + patch check. `coverage.yml` already runs on pull requests (not only on + push to `develop`), so the baseline is current — the failure is purely + a blocking patch status graded against unit-only data, not a stale + baseline. See + [discussion #69](https://github.com/orgs/easyscience/discussions/69). +- **Coverage is line-only and unenforced.** `fail_under = 65` is checked + locally but never gated in CI, and line coverage says nothing about + input-domain coverage (negative/zero/non-numeric inputs to numeric + code, out-of-range crystallographic values, etc.). +- **The mirrored structure is enforced by a script that CI never runs.** + `tools/test_structure_check.py` validates the `src` ↔ `tests/unit` + mirror (209/209 modules today) but is not wired into any workflow, so + drift can land. +- **No cross-engine numerical validation and no place to show it.** The + documentation has no section comparing calculated patterns or refined + parameters across calculation engines (`cryspy`, `crysfml`, `pdffit`) + or against external software (FullProf, GSAS-II). Engines are keyed + only by `scattering_type`, so there is no declared matrix of which + `beam_mode × radiation_probe` each engine supports. +- **No performance-regression control.** `tools/benchmark_tutorials.py` + records local-only, whole-tutorial wall-clock CSVs with no baseline, + no per-experiment granularity, and no gate. +- **No broad robustness check against real-world files.** Nothing + exercises EasyDiffraction against a large, varied corpus of CIF files + to catch parsing/recognition failures before users hit them. +- **Documentation drift is not caught on every push.** `docs.yml` + executes all tutorials and then builds and deploys the site; it is + slow and therefore runs on pull requests only. There is no fast, + every-push check that the site builds strictly, links resolve, and + prose is clean. This overlaps the unimplemented + [Documentation CI and Build Verification](../suggestions/documentation-ci-build.md) + suggestion. + +This ADR amends [Test Strategy](../accepted/test-strategy.md): the +five-layer decomposition stands, but its definitions become strict and +testable, and the strategy is extended to cover test cost tiers, +coverage policy, codecov configuration, cross-engine verification +documentation, performance benchmarks, a nightly validation harness, and +a fast documentation-build gate. It deliberately combines these into one +document because they are one coherent quality story with shared +infrastructure (markers, the data repository, CI triggers); large +sub-areas are explicitly phased and several are documented now but +implemented in follow-up pull requests. + +## Decision + +### 1. Strict layer definitions and placement criteria + +Replace the one-line definitions with observable, testable rules. A test +belongs to the **lowest** layer whose constraints it can satisfy. + +| Layer | May use | Must NOT use | Speed | +| --------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------- | +| **unit** | one module under test; in-process logic; `tmp_path` | real calculation engine; network / `download_data()`; filesystem outside `tmp_path`; `sleep`; subprocess | sub-second | +| **functional** | several modules / a workflow; small bundled fixtures | real calculation engine; **network / `download_data()`** | seconds | +| **integration** | real engines, real fits, real downloaded data; the only layer allowed network and real backends | — | slow; xdist | +| **script** | full tutorial `.py` executed subprocess-isolated | (already correct) | slow; xdist | +| **notebook** | generated `.ipynb` executed via `nbmake` | — | slow | + +Mocking a forbidden dependency (for example a mocked `download_data()`) +keeps a test in a lower layer **only when the mock is explicit**; an +implicit or accidental real call is a layer violation. + +Consequences for the current suite: + +- The functional tests that call real `download_data()` move to + integration (the hard line: functional is in-process with bundled + fixtures, never network). +- A one-time relocation pass moves slow, engine-adjacent, or + network-touching "unit" tests to their correct layer, and tags the + remainder per §2. +- The ADR ships a short "where does this test go?" decision list so + authors do not re-derive the boundary. + +### 2. Test cost tiers via opt-in escalation markers + +Cost tiers are **orthogonal** to layers. The default is fast; expensive +tests opt _into_ a heavier tier, so only the minority are tagged. + +- **default (unmarked):** fast. Runs on every push, every pull request, + and nightly. +- **`@pytest.mark.pr`:** heavier. Runs on pull requests and on + `develop`/`master`, skipped on intermediate feature-branch pushes. +- **`@pytest.mark.nightly`:** very expensive (the §8 corpus harness, + generative fuzzing, full cross-engine sweeps, full benchmarks). Runs + on the scheduled nightly job and on demand; never on ordinary pushes. + +Orthogonality holds at the **unit and functional** layers: those default +to fast, and an individual test opts into `pr`/`nightly`. The +**integration** layer is the one principled exception — _every_ +integration test uses a real engine and/or downloaded data, so the layer +**defaults to the `pr` tier**, applied once in +`tests/integration/conftest.py` rather than by tagging each of ~150 +tests. An integration test may still escalate to `nightly`. This keeps +feature-branch pushes fast (unit + functional only) without scattering +`@pytest.mark.pr` across the whole integration suite. + +CI marker selection: + +```text +feature-branch push: -m "not pr and not nightly" +pull request + main: -m "not nightly" +nightly schedule: (all markers, including -m nightly) +``` + +The current `fast` marker (which today selects a _cheap subset_ and is +applied to six integration files) is **retired**; its intent is inverted +into the scheme above. Markers are registered in +`[tool.pytest.ini_options].markers`. + +### 3. Mirrored unit structure as a CI gate + +`tools/test_structure_check.py` remains the canonical enforcer of the +`src/easydiffraction//.py` → +`tests/unit/easydiffraction//test_.py` mirror, including its +three match strategies (direct mirror, known aliases such as +`singleton → singletons` and `variable → parameters`, and parent-level +roll-up for `default.py`/`factory.py` category packages). It is added to +CI (the lint/format or test workflow) as a fast, static gate so +structural drift fails before merge. + +The check should be driven by a **single source-of-truth enumeration of +the `src/` tree**. `tools/generate_package_docs.py` already walks that +tree (`build_tree()`) to generate `docs/dev/package-structure/short.md` +and `full.md` (regenerated by `pixi run fix`). Today +`test_structure_check.py` walks `src/` independently, so the two can +drift. Reuse or adapt the existing tree-walk so structure generation and +the mirror check share one enumeration; the check fails CI and local +runs on any discrepancy between `src/` and `tests/unit/`. The related +scaffold generator `tools/gen_tests_scaffold.py` stays the way authors +create the mirrored test file for a new module. + +### 4. Coverage policy: line/branch and input-domain + +Two distinct bars, because line coverage and case coverage are different +guarantees. + +- **Line/branch coverage.** Raise `fail_under` from 65 to **80 now**, + with a documented ramp toward **90–95** as the suite fills, and gate + it in CI through the codecov project status (§5) rather than only + locally. +- **Validators are the input boundary.** All user input is verified at + runtime through the project's custom validator framework in + `src/easydiffraction/core/validation.py`: an `AttributeSpec` pairs a + `TypeValidator` (data type) with a content `ValidatorBase` subclass + (membership, range, and similar), and parameter (`core/variable.py`) + and category (`core/category.py`) classes route writes through it. + Input-domain tests therefore target the **validators directly** — both + that they accept the full valid domain and that they reject (or fall + back, per their contract) on invalid values — rather than re-checking + the same boundaries at every call site. This matches the project + principle of explicit handling at the boundary and no defensive + padding past it. +- **Input-domain coverage.** Adopt **property-based testing with + `hypothesis`** for the validator-guarded numeric and crystallographic + inputs: cell lengths (> 0), cell angles (valid ranges and lattice + constraints), fractional coordinates, site occupancies ∈ [0, 1], + ADP/`Biso` positivity and ranges, space-group numbers (1–230), + wavelengths (> 0), plus rejection of wrong-typed input + (int/float/str). `hypothesis` runs in a **deterministic profile** + (`derandomize`, fixed seed, no committed `.hypothesis` database) to + honour the no-flakiness and no-ordering-dependence rules. + Known-critical boundary cases are also written as **explicit + parametrised tables** so they are visible and named; `hypothesis` adds + generative exploration on top. +- **Numeric tolerance convention.** Replace the scattered mix of + `pytest.approx`, `np.testing.assert_allclose`, and + `assert_almost_equal(decimal=...)` with one documented intra-engine + `rtol`/`atol` pair and one cross-engine pair, defined once (a root + `tests/conftest.py` fixture) and referenced everywhere. + +### 5. Codecov policy + +The always-red patch status has a single cause: a **blocking `patch` +status (`target: auto`) graded against a unit-only coverage upload**. +Diff lines exercised mainly by functional, integration, or script tests +show near-zero unit coverage and fail the patch check. `coverage.yml` +already uploads unit coverage on pull requests and on push to `develop`, +so the baseline is current; only the status configuration needs to +change — **no new coverage-upload path is introduced**. + +Adopt the recommendation from +[discussion #69](https://github.com/orgs/easyscience/discussions/69): + +- **Upload unit-test coverage only** (keep the single, fast, reliable + source already produced by `coverage.yml`; functional/integration + coverage stays out of codecov). +- **`project` status: target 80%, blocking** (`informational: false`) — + this becomes the real coverage gate. +- **`patch` status: `informational: true`** (non-blocking) — stops the + always-red patch failures, which were an artefact of grading diff + lines against a unit-only baseline. + +### 6. Verification documentation (cross-engine pattern comparison) + +Add a new top-level **Verification** section to the documentation nav +(between Tutorials and Command-Line), generated like tutorials (`.py` +source → notebook via `pixi run notebook-prepare`, built with +`execute: false`). + +- **Calculation-only comparisons (no minimisation).** Feed identical + input parameters to each supported engine, compute patterns, and + compare them pairwise (`ed-cryspy`, `ed-crysfml`, … and later + `fullprof`). This is far faster than fitting, so the same pages double + as **fast regression scripts** under `script-tests`. +- **Metrics.** Report clear, documented closeness metrics per pair — a + profile-difference metric (Rwp-style), maximum point-wise deviation, + and an integrated-intensity ratio — with explicit tolerances. +- **Overlay plots.** Plot all engines on one chart with distinct colours + and line styles (solid/dotted/…) for visual comparison. +- **Coverage of conditions.** Grow to cover **every valid experiment × + instrument-parameter combination at least once** (powder/single + crystal × constant-wavelength/time-of-flight × neutron/x-ray × + bragg/total, per the support matrix below). The section ships with the + framework and the first cross-engine comparison (constant-wavelength + powder, cryspy ↔ crysfml); the remaining supported combinations + (time-of-flight powder, single crystal) are added **incrementally** + and tracked in the open-issues list. +- **External software, incrementally.** External tools (FullProf first, + then GSAS-II/TOPAS) are compared by loading a **pre-calculated profile + from a zipped project** stored in the `diffraction` data repository + (§8), so EasyDiffraction need not run them. The page structure ships + now with an external placeholder; data is added incrementally. +- **Prerequisite — engine support matrix.** Declare which engine + supports which `beam_mode × radiation_probe` (and `scattering_type`) + via the existing `CalculatorSupport`/`Compatibility` metadata, so + "every valid combination" is well-defined. Today engines are keyed + only by `scattering_type` at the factory level. The precise metadata + wiring is a scoped sub-task (see Deferred Work). + +### 7. Performance-regression benchmarks + +Replace the ad-hoc `tools/benchmark_tutorials.py` CSV tool with +**`pytest-benchmark`**, matching the prior art in `deps-pycrysfml`: + +- Benchmark **per experiment type** (one benchmark per + `beam_mode × radiation_probe × engine`) rather than whole-tutorial + wall-clock. +- Store baseline JSON (full machine info + per-benchmark statistics) in + the `diffraction` data repository (§8), written by a CI job. +- **Informational now** (no gating), to avoid false failures from noisy + CI timing. A regression gate (`--benchmark-compare-fail`) is added + later once variance is characterised, ideally on a dedicated runner + (see Deferred Work). Benchmarks run in the `nightly` tier. + +### 8. Nightly validation harness (CIF corpus and generative fuzzing) + +A robustness harness exercising EasyDiffraction against many real and +synthetic structures. + +- **Code vs data split.** Harness _code_ lives in `diffraction-lib` + (`tests/nightly/`, `@pytest.mark.nightly`) so it versions with the + code it checks. The _corpus_, _results database_, FullProf profiles, + and benchmark baselines live in the **`diffraction` data repository** + (consistent with `download_data()`), fetched at runtime. +- **Acceptance-style run.** A scheduled nightly CI job installs + EasyDiffraction **from PyPI**, runs the harness, and writes results + back to the data repository via a bot commit (the `deps-pycrysfml` + "auto-push" pattern). The same harness runs locally on demand. +- **CIF corpus check.** Download ~100–200 CIF files from the + Crystallography Open Database (COD), load each, and record per-file + status: + - `ok` — parsed, all recognised; + - `partial` — parsed, some information missing (EasyDiffraction + applied defaults), with a comment naming what was not recognised; + - `fail` — could not be parsed, with the error. The status lets the + harness (a) skip re-downloading already-`ok` files on later nights + and (b) flag genuine EasyDiffraction recognition bugs vs malformed + files; problematic files convert to issues. +- **Results database — CSV.** A git-diffable manifest, **one row per CIF + keyed by COD id and ordered by id** (so new files insert in order): + `id, parse_status, missing_fields, calc_status_per_engine, comment, last_checked`. + CSV keeps diffs reviewable and issue-friendly. +- **Re-check flag.** The harness script exposes a flag to **re-run only + the failed/partial entries already in the database** (rather than + drawing new random files from COD), so fixes in EasyDiffraction can be + re-validated against the exact files that previously failed. +- **Cross-engine calculation on the corpus.** For loadable structures, + call each supported calculator, compare patterns, and store the + per-engine result (with comments) in the same database. +- **Generative fuzzing (documented now, implemented later).** Randomly + generate ~100–200 structures (random space group, cell parameters, + 1–10 atoms with random coordinates/ADP/occupancy), compute patterns + across engines, and record disagreements in the same database. This + reuses the corpus harness and database; its implementation is a + follow-up pull request (see Deferred Work). + +### 9. Fast documentation-build gate in the test workflow + +Add a **fast, every-push job to `test.yml`** that does **not execute +tutorials**: + +- `mkdocs build --strict` (catches missing nav entries and broken + internal references; tutorials build with `execute: false`), +- link checking (`lychee` or equivalent, with an allowlist), and +- spelling/grammar (`codespell` first; `Vale` later, see Deferred Work). + +This is expected to take about a minute and runs on every push, giving +prompt drift feedback. It is deliberately **separate from `docs.yml`**, +which executes all tutorials and then builds and deploys — slow, and +therefore pull-request-only. The detailed catalogue of documentation +checks is owned by +[Documentation CI and Build Verification](../suggestions/documentation-ci-build.md), +which this ADR coordinates with: that ADR defines _what_ the checks are; +this ADR's decision is that the cheap, deterministic subset runs as part +of the every-push test workflow. Promoting that ADR is part of this +work. + +Known limitation: links that appear **only inside executed notebook +output cells** (for example a generated table linking to parameter +definitions) are invisible to the non-executing strict-build job. That +output-cell-link feature does not exist yet; the limitation is recorded +for the future rather than solved now. + +### Implementation phasing + +1. **Quick wins:** §5 codecov policy, §3 structure-check gate, §9 fast + docs gate, §2 markers + §1 placement rules and the relocation pass. +2. **Coverage and cases:** §4 `fail_under` 80 + `hypothesis` + tolerance + convention. +3. **Verification + benchmarks:** §6 engine support matrix and + calculation-only comparison pages; §7 `pytest-benchmark`. +4. **Nightly harness:** §8 corpus check and results database; generative + fuzzing in a later pull request. + +## Consequences + +### Positive + +- A test's correct layer and cost tier are decidable from written rules, + not judgement, so the suite stops drifting. +- Codecov patch stops failing spuriously and the project status becomes + a meaningful, enforced 80% gate. +- Coverage gains a case-quality dimension (input domains, boundaries, + wrong types), not just line counts. +- Cross-engine and (later) external agreement is visible to scientists + in the documentation and regression-checked cheaply. +- Performance and real-world-file robustness gain dedicated, low-noise + signals without slowing ordinary development. +- Documentation drift is caught on every push in about a minute. + +### Trade-offs + +- Relocating functional/unit tests and retiring `fast` touches many + existing test files in one pass. +- New dependencies (`hypothesis`, `pytest-benchmark`, plus `codespell` + and a link checker for the docs job) add configuration and + maintenance. +- The nightly harness and data-repository round-trip add CI and + cross-repository coordination. +- Raising `fail_under` to 80 and gating it can block merges until + coverage catches up; the ramp is deliberate. + +## Alternatives Considered + +- **One combined ADR vs several focused ADRs.** A split (taxonomy / + codecov / benchmarks / verification) was considered. Chosen: one + combined ADR, because the goals share infrastructure (markers, the + data repository, CI triggers) and read as one quality story; large + sub-areas are phased instead. +- **Upload combined coverage to codecov.** Rejected for now: slower, + flakier (engine-dependent), and needs per-flag setup. Unit-only upload + with a non-blocking patch status is simpler and fixes the reported + pain directly. +- **Keep the current `fast` marker semantics.** Rejected: marking the + cheap majority is more error-prone than opt-in escalation of the + expensive minority. +- **`asv` for benchmarking.** Rejected vs `pytest-benchmark`: `asv` + wants a dedicated dashboard/runner; `pytest-benchmark` reuses pytest, + matches `deps-pycrysfml`, and supports committed JSON baselines. +- **SQLite results database.** Rejected as the source of truth: opaque + in diffs and harder to convert to issues. CSV keyed and ordered by id + is reviewable; a derived cache can be added later if querying demands + it. +- **`syrupy` snapshot testing and `mutmut` mutation testing.** Deferred, + not adopted now (see Deferred Work): the tutorial `baseline.json` + already covers fit-result regression, and mutation testing is only + meaningful once line coverage is solid. + +## Deferred Work + +- Generative random-structure fuzzing implementation (§8) — follow-up + pull request. +- External-software reference data and comparisons (FullProf, then + GSAS-II/TOPAS) for the Verification section (§6). +- Benchmark regression gating threshold and a dedicated, low-noise + runner (§7). +- Precise `CalculatorSupport`/`Compatibility` wiring for the engine + support matrix (§6 prerequisite); may warrant its own short ADR. +- `Vale` prose linting after `codespell` has a baseline and a + crystallography/CIF-tag vocabulary (§9). +- Link-checking of URLs that appear only in executed notebook output + cells (§9 limitation); revisit if/when that output feature exists. +- Mutation testing (`mutmut`) once line coverage reaches ≥ 80%. +- Snapshot testing (`syrupy`) for CIF/report output — reconsider if + explicit assertions prove insufficient. +- The exact coverage ramp schedule from 80% toward 90–95%. + +## Dependencies + +New dependencies introduced by this ADR (approval recorded in the +drafting conversation, per the dependency-approval rule): + +- `hypothesis` — property-based / input-domain testing (§4). +- `pytest-benchmark` — performance-regression benchmarks (§7). + +Coordinated with +[Documentation CI and Build Verification](../suggestions/documentation-ci-build.md), +which carries the documentation-check tools (`codespell`, a link checker +such as `lychee`, and later `Vale`) used by §9. + +## Related ADRs + +- [Test Strategy](../accepted/test-strategy.md) — amended by this ADR. +- [Documentation CI and Build Verification](../suggestions/documentation-ci-build.md) + — coordinated with §9. +- [Lint Complexity Thresholds](../accepted/lint-complexity-thresholds.md) + — sibling Quality guardrail. +- [Notebook Generation Source of Truth](../accepted/notebook-generation.md) + — the `.py` → notebook pipeline reused by §6. +- [Factory Contracts and Metadata](../accepted/factory-contracts.md) — + the `CalculatorSupport`/`Compatibility` metadata used by §6. +- [Enum-Backed Closed Value Sets](../accepted/enum-backed-closed-values.md) + — any new closed set (engine tags, experiment axes) stays + `(str, Enum)`. diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md index 6b9f3379a..4733588fb 100644 --- a/docs/dev/adrs/index.md +++ b/docs/dev/adrs/index.md @@ -13,48 +13,49 @@ 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 | Accepted | Plotting & Docs Performance for Interactive Figures | Self-hosts a lazy, shared figure runtime so docs pages load fast and progressively while staying interactive. | [`plotting-docs-performance.md`](accepted/plotting-docs-performance.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 | Accepted | Automatic Line-Segment Background Estimation | Detects line-segment background control points from the measured pattern, peak-insensitive and editable. | [`background-auto-estimate.md`](accepted/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 | 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) | -| 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 | 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) | +| 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 | Accepted | Plotting & Docs Performance for Interactive Figures | Self-hosts a lazy, shared figure runtime so docs pages load fast and progressively while staying interactive. | [`plotting-docs-performance.md`](accepted/plotting-docs-performance.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 | Accepted | Automatic Line-Segment Background Estimation | Detects line-segment background control points from the measured pattern, peak-insensitive and editable. | [`background-auto-estimate.md`](accepted/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) | +| Quality | Accepted | Test Suite and Validation Strategy | Strict test layers, cost tiers, coverage/codecov policy, cross-engine verification docs, and a nightly validation harness. | [`test-suite-and-validation.md`](accepted/test-suite-and-validation.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 | 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) | +| 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 | 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/issues/open.md b/docs/dev/issues/open.md index 89434ef8f..42e37755c 100644 --- a/docs/dev/issues/open.md +++ b/docs/dev/issues/open.md @@ -1886,6 +1886,106 @@ only render the index column when no explicit id column is present. --- +## 113. 🟡 Cross-Repository Validation Harness (nightly) + +**Type:** Test infrastructure + +Deferred cross-repository work for the +[Test Suite and Validation Strategy](../adrs/accepted/test-suite-and-validation.md) +ADR (§7, §8). The harness code lives in `diffraction-lib`; the corpus, +results database, and benchmark/reference history live in the +`diffraction` data repository, fetched at runtime and written back by a +nightly job that installs easydiffraction from PyPI (acceptance-style). + +**Items:** + +- **COD corpus check.** Download ~100–200 CIF files from the + Crystallography Open Database, load each, and record per-file status + (`ok` / `partial` + missing fields / `fail`) in a git-diffable CSV + keyed and ordered by COD id. Add a `--recheck-failed` flag to re-run + only the failed/partial entries after fixes (instead of new random + files). Extend with per-engine calculation results on the corpus. +- **Generative fuzzing.** Randomly generate ~100–200 structures (random + space group, cell, 1–10 atoms with random coordinates/ADP/occupancy), + compute patterns across engines, and record disagreements in the same + database. +- **Benchmark history + gate.** Commit `pytest-benchmark` baseline JSON + to the data repository and add a regression threshold once timing + variance is characterised on a controlled runner. (The serial + benchmark task itself now exists — see issue 16 — this is the + history/gating remainder.) +- **External-software comparison.** Add FullProf (then GSAS-II/TOPAS) + pre-calculated profiles as zipped projects in the data repository so + the Verification pages can overlay them against easydiffraction. + +**Depends on:** the `diffraction` data repository; cross-repo +coordination. + +--- + +## 114. 🟢 External Link Checking in the Docs Gate + +**Type:** CI / Documentation + +The fast docs gate (`docs-build` + `link-check` + `spell-check`) catches +broken nav/internal links and typos on every push, but does not yet +check external URLs. Add a `lychee` link checker (with an allowlist for +rate-limited/unstable domains), coordinated with the +[Documentation CI and Build Verification](../adrs/suggestions/documentation-ci-build.md) +ADR. Run it nightly or on pull requests to avoid flakiness from external +sites. Also covers link-checking of URLs that appear only inside +executed notebook output cells (a feature that does not exist yet). + +**Depends on:** nothing. + +--- + +## 115. 🟢 Expand Cross-Engine Verification Coverage + +**Type:** Test coverage / Documentation + +The Verification docs section ships with the framework and the first +cross-engine comparison page (constant-wavelength powder, cryspy ↔ +crysfml). Extend it to the remaining supported combinations declared by +the calculator support matrix — time-of-flight powder (cryspy ↔ crysfml) +and single crystal — so every valid experiment/instrument combination is +documented and regression-checked at least once. Each new page is a +calculation-only `.py` under `docs/docs/verification/` wired into +`script-tests` and `notebook-tests`, with explicit metric tolerances. + +**Depends on:** nothing. + +--- + +## 116. 🟡 Add a Static Type Checker to the Quality Gate + +**Type:** Tooling / Correctness + +The project type-annotates public signatures but runs no static type +checker (only ruff's `TC` import-placement rules). A genuine bug slipped +through as a result: +`Plotter._plot_single_crystal_posterior_predictive_summary` called +`PlotlyPlotter._get_diagonal_shape()` with no arguments while the method +requires `(minimum, maximum)`, so the single-crystal posterior- +predictive plot raised `TypeError` whenever reached. A type checker +would have flagged the wrong-arity call at lint time, without needing a +test to exercise the path — and would catch the whole class of such +errors. + +**Fix:** add a checker (mypy, pyright, or `ty`) as a `pixi` task wired +into `check` and the lint CI workflow, alongside the existing ruff / +pydoclint / interrogate gates. Roll out incrementally to manage the +initial error backlog: start lenient (e.g. `--follow-imports=silent` or +a per-package allowlist) and gate only new/changed code first, then +tighten. The codebase already annotates public signatures, so it is +well-positioned. + +**Depends on:** nothing. Best landed as its own focused effort because +enabling a checker on an existing codebase surfaces a backlog that needs +a baseline-cleanup plan. + +--- + ## Summary | # | Issue | Severity | Type | @@ -1980,3 +2080,7 @@ only render the index column when no explicit id column is present. | 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 | +| 113 | Cross-repository validation harness (nightly) | 🟡 Med | Test infrastructure | +| 114 | External link checking in the docs gate | 🟢 Low | CI / Documentation | +| 115 | Expand cross-engine verification coverage | 🟢 Low | Test coverage | +| 116 | Add a static type checker to the quality gate | 🟡 Med | Tooling / Correctness | diff --git a/docs/dev/package-structure/full.md b/docs/dev/package-structure/full.md index 58991c95f..a242281cc 100644 --- a/docs/dev/package-structure/full.md +++ b/docs/dev/package-structure/full.md @@ -14,8 +14,10 @@ │ │ │ └── 🏷️ class CryspyCalculator │ │ ├── 📄 factory.py │ │ │ └── 🏷️ class CalculatorFactory -│ │ └── 📄 pdffit.py -│ │ └── 🏷️ class PdffitCalculator +│ │ ├── 📄 pdffit.py +│ │ │ └── 🏷️ class PdffitCalculator +│ │ └── 📄 support.py +│ │ └── 🏷️ class SupportEntry │ ├── 📁 categories │ │ ├── 📁 aliases │ │ │ ├── 📄 __init__.py @@ -507,8 +509,6 @@ │ │ │ ├── 📄 elements.py │ │ │ └── 📄 radii.py │ │ ├── 📁 renderers -│ │ │ ├── 📁 vendor -│ │ │ │ └── 📁 threejs │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 ascii.py │ │ │ │ ├── 🏷️ class _Orientation @@ -672,7 +672,6 @@ ├── 📁 report │ ├── 📁 templates │ │ ├── 📁 html -│ │ │ └── 📁 vendor │ │ └── 📁 tex │ ├── 📄 __init__.py │ ├── 📄 data_context.py @@ -685,12 +684,6 @@ │ ├── 📄 style.py │ └── 📄 tex_renderer.py ├── 📁 utils -│ ├── 📁 _vendored -│ │ ├── 📁 jupyter_dark_detect -│ │ │ ├── 📄 __init__.py -│ │ │ └── 📄 detector.py -│ │ ├── 📄 __init__.py -│ │ └── 📄 theme_detect.py │ ├── 📄 __init__.py │ ├── 📄 enums.py │ │ └── 🏷️ class VerbosityEnum diff --git a/docs/dev/package-structure/short.md b/docs/dev/package-structure/short.md index ec8c02af6..35b7863fb 100644 --- a/docs/dev/package-structure/short.md +++ b/docs/dev/package-structure/short.md @@ -9,7 +9,8 @@ │ │ ├── 📄 crysfml.py │ │ ├── 📄 cryspy.py │ │ ├── 📄 factory.py -│ │ └── 📄 pdffit.py +│ │ ├── 📄 pdffit.py +│ │ └── 📄 support.py │ ├── 📁 categories │ │ ├── 📁 aliases │ │ │ ├── 📄 __init__.py @@ -248,8 +249,6 @@ │ │ │ ├── 📄 elements.py │ │ │ └── 📄 radii.py │ │ ├── 📁 renderers -│ │ │ ├── 📁 vendor -│ │ │ │ └── 📁 threejs │ │ │ ├── 📄 __init__.py │ │ │ ├── 📄 ascii.py │ │ │ ├── 📄 base.py @@ -327,7 +326,6 @@ ├── 📁 report │ ├── 📁 templates │ │ ├── 📁 html -│ │ │ └── 📁 vendor │ │ └── 📁 tex │ ├── 📄 __init__.py │ ├── 📄 data_context.py @@ -338,12 +336,6 @@ │ ├── 📄 style.py │ └── 📄 tex_renderer.py ├── 📁 utils -│ ├── 📁 _vendored -│ │ ├── 📁 jupyter_dark_detect -│ │ │ ├── 📄 __init__.py -│ │ │ └── 📄 detector.py -│ │ ├── 📄 __init__.py -│ │ └── 📄 theme_detect.py │ ├── 📄 __init__.py │ ├── 📄 enums.py │ ├── 📄 environment.py diff --git a/docs/dev/plans/test-suite-and-validation.md b/docs/dev/plans/test-suite-and-validation.md new file mode 100644 index 000000000..6014dd49a --- /dev/null +++ b/docs/dev/plans/test-suite-and-validation.md @@ -0,0 +1,385 @@ +# Plan: Test Suite and Validation Strategy + +This plan follows [`AGENTS.md`](../../../AGENTS.md) with one **declared +exception** to the two-phase workflow. [`AGENTS.md`](../../../AGENTS.md) +§Workflow keeps test creation in Phase 2 ("Phase 1 — Code and docs +updates only ... Do not create or run tests unless the user explicitly +asks"). Because this ADR's subject _is_ the test suite, Phase 1 here +necessarily includes test relocation, shared fixtures, new unit/property +tests, and benchmark tests as implementation work — deferring them to +verification would leave Phase 1 empty of its actual deliverable. The +implementer running `/draft-impl-1` will therefore edit and add files +under `tests/**` during Phase 1. Phase 2 remains the standard +verification gate (`pixi run fix`/`check`/`unit-tests`/ +`integration-tests`/`script-tests`) and does not author new test suites. + +A scope decision is also recorded below (§Scope): the cross-repository +work is documented for the future rather than implemented in this +branch, per the author's instruction. + +## ADR + +Implements the suggestion ADR +[`test-suite-and-validation.md`](../adrs/accepted/test-suite-and-validation.md) +(drafted via `/draft-adr`; review cycle closed). The plan owns this ADR. +Per [`AGENTS.md`](../../../AGENTS.md) §Change Discipline, the ADR is +promoted from `suggestions/` to `accepted/` as part of this change, +before the PR is opened (step P1.15). + +Coordinated, not implemented here: +[`documentation-ci-build.md`](../adrs/suggestions/documentation-ci-build.md) +(stays a suggestion; this plan implements only its strict-build, link, +and spelling subset for the every-push test workflow — see §Open +questions). + +Amends the accepted +[`test-strategy.md`](../adrs/accepted/test-strategy.md) (sharper layer +definitions). + +## Branch and PR + +- Branch: `test-suite-and-validation` (already checked out, created off + `develop`). +- PR target: `develop`. +- Do not push the branch until asked. + +## Scope + +**In scope (all in-repository ADR work):** §1 strict layers + +relocation, §2 cost-tier markers, §3 structure-check CI gate, §4 +coverage target + `hypothesis`, §5 codecov policy, §6 engine support +matrix + cross-engine (cryspy ↔ crysfml) calculation-only comparison +pages, §7 `pytest-benchmark` suite, §9 fast docs build gate. + +**Documented for the future (cross-repository, NOT implemented here):** + +- §8 nightly COD corpus harness, its results database, and the + pip-install acceptance CI job that writes results back to the + `diffraction` data repository. +- §8 generative random-structure fuzzing (already ADR-deferred). +- §7 benchmark baseline history committed to the `diffraction` data + repository, and any performance regression gate. +- §6 external-software comparison (FullProf/GSAS-II) via zipped projects + stored in the `diffraction` data repository. + +These are captured in step P1.14 (a future-work record in +`docs/dev/issues/open.md`) and remain in the ADR's Deferred Work. + +## Decisions (already made) + +- **Layers (§1):** functional is in-process with bundled fixtures only — + **no real calculation engine and no network/`download_data()`**; those + move to integration. A test goes to the lowest layer whose constraints + it can satisfy. +- **Markers (§2):** opt-in escalation. Default (unmarked) = fast. Add + `@pytest.mark.pr` (PR + `develop`/`master`) and `@pytest.mark.nightly` + (scheduled only). **Retire the current `fast` marker** (6 integration + files). CI selection: `not pr and not nightly` (feature push) / + `not nightly` (PR + main) / all (nightly schedule). Because markers + are **orthogonal to layers**, the selected expression is applied to + **every** pytest invocation under the policy — unit, functional, and + integration, in both the source and package CI jobs — not to + integration alone as today. The **integration layer defaults to the + `pr` tier** (auto-marked once in `tests/integration/conftest.py`) + because every integration test uses a real engine; unit/functional + default to fast and escalate individually. +- **Structure gate (§3):** unify on a single `src/` tree walk shared by + `tools/generate_package_docs.py` and `tools/test_structure_check.py`; + run the check in CI as a gate. +- **Coverage (§4):** raise `fail_under` 65 → 80 (ramp to 90–95 later). + Adopt `hypothesis` (approved) in a deterministic profile; input-domain + tests target the validators in `core/validation.py`. One documented + numeric-tolerance convention via a root `tests/conftest.py`. +- **Codecov (§5):** unit-only upload (unchanged), `patch` → + `informational: true`, `project` → target 80% blocking. No new upload + path. +- **Verification (§6):** calculation-only (no minimisation) cross-engine + comparison pages with profile-difference / max-deviation / + integrated-intensity metrics and overlay plots; new top-level + `Verification` nav node; pages double as `script-tests`. Engine + support matrix declared first. +- **Benchmarks (§7):** `pytest-benchmark` (approved), per + `beam_mode × radiation_probe × engine`, `nightly`-marked, output as a + CI artifact; informational. +- **Docs gate (§9):** fast every-push job in `test.yml` — strict + `mkdocs build` (no tutorial execution), link check (`lychee`), spell + check (`codespell`) — separate from the slow `docs.yml`. +- **Dependencies named for pre-approval** (per + [`AGENTS.md`](../../../AGENTS.md) §Architecture): `hypothesis`, + `pytest-benchmark`, `codespell` (dev dependencies); `lychee` (CI + link-checker, GitHub Action or binary — no Python dependency). +- **Added during Phase 2 verification by direct user request** + (`AGENTS.md` §Architecture first approval path — a direct user request + in the conversation, 2026-06-06): `pytest-randomly` (dev dependency). + It randomises unit-test order to flush out latent test-ordering + dependence; it surfaced and fixed two such tests and is paired with a + `tests/unit/conftest.py` global-state reset fixture. This was not in + the original pre-approval list above; the approval is recorded here + for traceability. + +## Open questions + +1. **Engine support matrix — extend existing metadata (§6, P1.11).** The + `TypeInfo`/`Compatibility`/`CalculatorSupport` dataclasses already + exist in `src/easydiffraction/core/metadata.py` (lines 19, 40, 88) + and are populated per instrument category (e.g. + `datablocks/experiment/categories/instrument/cwl.py` declares + `compatibility` and `calculator_support`). P1.11 therefore _applies + and extends_ this model — notably adding `radiation_probe` to + `Compatibility` if missing, and exposing a query to enumerate + comparable engine × condition combinations — rather than introducing + new classes. **Stop and ask** about a dedicated ADR only if extending + the metadata shape proves structural (a broad change to + `Compatibility`). +2. **`fail_under = 80` feasibility (§4, P1.9).** Current unit-only + coverage is unverified against 80. If Phase 2 shows it below 80 after + the new tests, either add more unit tests or set a documented + intermediate value and ramp — decide in Phase 2, do not silence the + gate. +3. **`documentation-ci-build` promotion (§9).** The ADR text says + promoting it "is part of this work," but this plan implements only a + subset (strict build + link + spell). Recommendation: keep it a + suggestion and cross-reference; revisit promotion when its remaining + items (mkdocstrings, snippet smoke tests, notebook-freshness) land. +4. **`lychee` packaging.** GitHub Action vs pinned binary in the pixi + environment — pick during P1.10. +5. **Location of the strict-criteria testing guide and the + tolerance-convention text (§1/§4).** A new `docs/dev/` testing guide + vs extending the amended `test-strategy.md` — decide in P1.5. + +## Concrete files likely to change + +- `.codecov.yml` (status config) +- `pyproject.toml` (`[tool.pytest.ini_options].markers`, + `[tool.coverage.report].fail_under`, `hypothesis`/`codespell` config, + dev dependencies) +- `pixi.toml` (new tasks: `docs-build-strict`, `link-check`, + `spell-check`, `benchmarks`; structure-check wiring; deps) +- `.github/workflows/test.yml` (marker selection, nightly scheduled job, + strict-docs job, structure-check gate) +- `tools/test_structure_check.py`, `tools/generate_package_docs.py` + (shared tree walk) +- `tests/conftest.py` (new: seeded RNG + tolerance fixtures) +- `tests/functional/**`, `tests/integration/**`, `tests/unit/**` + (relocation; remove `fast` marks; new property tests) +- `tests/integration/fitting/*.py` (6 files: remove `@pytest.mark.fast`) +- `tests/benchmarks/**` (new: `nightly` benchmarks) +- `src/easydiffraction/core/metadata.py`, + `src/easydiffraction/datablocks/experiment/categories/instrument/**`, + `src/easydiffraction/analysis/calculators/**` (engine support matrix — + extend existing `Compatibility`/`CalculatorSupport`) +- `src/easydiffraction/core/validation.py` (property-test target; expose + domains if needed) +- `docs/mkdocs.yml` (Verification nav node) +- `docs/docs/verification/*.py` (new comparison tutorials) +- `docs/dev/adrs/suggestions/test-suite-and-validation.md` → `accepted/` + (promotion); `docs/dev/adrs/index.md` (status flip) +- `docs/dev/issues/open.md` (cross-repo future-work record) +- new config: `.codespellrc` (or `[tool.codespell]`), `lychee` config + +## Implementation discipline + +When an AI agent follows this plan, **every completed Phase 1 step must +be staged with explicit paths and committed locally before moving to the +next step or the Phase 1 review gate**, per +[`AGENTS.md`](../../../AGENTS.md) §Commits. Keep commits atomic, +single-purpose, and aligned to the step. Do not stage unrelated dirty +files or generated artifacts. Do not run Phase 2 commands during +Phase 1. + +## Implementation steps (Phase 1) + +- [x] **P1.1 — Codecov status policy (§5)** Edit `.codecov.yml`: add + `informational: true` to `patch.default`; set `project.default` to + `target: 80%`, `informational: false`. Leave the unit-only upload + untouched. Files: `.codecov.yml`. Commit: + `Make codecov patch informational and gate project at 80%` + +- [x] **P1.2 — Cost-tier markers and test retagging (§2)** Register `pr` + and `nightly` markers in `[tool.pytest.ini_options].markers`; + remove the `fast` marker definition. Remove `@pytest.mark.fast` + from the 6 `tests/integration/fitting/*.py` files (retag the + genuinely heavy ones with `pr` where appropriate). Files: + `pyproject.toml`, `tests/integration/fitting/*.py`. Commit: + `Replace fast marker with pr and nightly test tiers` + +- [x] **P1.3 — CI marker selection across all layers and nightly job + (§2)** Update `.github/workflows/test.yml` mark logic to + `-m "not pr and not nightly"` (feature push) and + `-m "not nightly"` (PR + `develop`/`master`), and apply the + selected expression to **every** pytest invocation in both the + source-test and package-test jobs — unit, functional, and + integration (today `-m` reaches only the integration runs at lines + 132 and 311; unit/functional run unfiltered). Thread a marker + passthrough into the `unit-tests` and `functional-tests` pixi + tasks (the `integration-tests` task already accepts an appended + expression). Add a `schedule:` trigger and a nightly job running + `-m nightly`. Files: `.github/workflows/test.yml`, `pixi.toml`. + Commit: `Select test tiers per trigger across all test layers` + +- [x] **P1.4 — Unify src-tree walk and gate structure check (§3)** + Extract the `src/` enumeration so `tools/test_structure_check.py` + and `tools/generate_package_docs.py` share one walker; add the + check to CI (lint/format or test workflow) as a blocking gate. + Files: `tools/test_structure_check.py`, + `tools/generate_package_docs.py`, `.github/workflows/*.yml`, + `pixi.toml`. Commit: + `Gate unit-test structure check on shared src tree walk` + +- [x] **P1.5 — Strict layer-criteria testing guide (§1)** Write the + may/must-not criteria and the "where does this test go?" decision + list (location per Open question 5), and tighten the layer wording + referenced by the amended `test-strategy.md`. Files: new + `docs/dev/` testing guide (or `test-strategy.md` update). Commit: + `Document strict test layer placement criteria` + +- [x] **P1.6 — Test relocation pass (§1)** Move functional tests that + call real `download_data()` into integration; relocate or + correctly mark slow/engine/network-touching unit tests (the 16 + `download_data()` unit call sites must be explicit mocks or move + out). Keep `test-structure-check` green. Files: + `tests/functional/**`, `tests/integration/**`, `tests/unit/**`. + Commit: `Relocate network and engine tests to correct layers` + +- [x] **P1.7 — Shared fixtures, hypothesis profile, tolerance convention + (§4)** Add `hypothesis` (dev dep) and a deterministic profile + (`derandomize`, fixed seed, no committed `.hypothesis` DB). Add a + root `tests/conftest.py` with seeded-RNG and one documented + `rtol`/`atol` pair (intra-engine) and one cross-engine pair. + Files: `pyproject.toml`, `pixi.toml`, `tests/conftest.py`, testing + guide. Commit: + `Add hypothesis deterministic profile and shared test fixtures` + +- [x] **P1.8 — Input-domain property tests on validators (§4)** + Property-based + explicit boundary-table tests against + `core/validation.py` (`TypeValidator`, content `ValidatorBase` + subclasses) through parameter (`core/variable.py`) and category + (`core/category.py`): valid-domain acceptance and invalid/ + wrong-type rejection or fallback per contract. Files: + `tests/unit/easydiffraction/core/**` (validator/variable/ category + tests). Commit: + `Add property-based input-domain tests for validators` + +- [x] **P1.9 — Raise coverage gate to 80% (§4)** Set + `[tool.coverage.report] fail_under = 80`. (Resolve Open question 2 + in Phase 2 if unit coverage is below 80 after P1.8.) Files: + `pyproject.toml`. Commit: + `Raise coverage fail_under to 80 percent` + +- [x] **P1.10 — Fast docs build gate (§9)** Add `docs-build-strict` + (`mkdocs build --strict`, tutorials not executed), `link-check` + (`lychee`), and `spell-check` (`codespell`) pixi tasks with config + and ignore lists; add a fast every-push job to `test.yml`. Add + `codespell` dev dep; wire `lychee` (Open question 4). Files: + `pixi.toml`, `pyproject.toml`, `.github/workflows/test.yml`, + `.codespellrc`, `lychee` config. Commit: + `Add strict docs build, link, and spell checks on every push` + +- [x] **P1.11 — Apply/extend calculator support metadata (§6 + prerequisite)** Build on the existing + `Compatibility`/`CalculatorSupport` model in `core/metadata.py` + (already declared per instrument category): add `radiation_probe` + to `Compatibility` if missing, and add a small query helper to + enumerate comparable engine × experiment-condition combinations + for the verification pages. Prefer this declared metadata over the + ad-hoc per-calculator `if beam_mode == …` checks. **Stop and ask** + only if extending the metadata shape proves structural (Open + question 1). Files: `src/easydiffraction/core/metadata.py`, + `src/easydiffraction/datablocks/experiment/categories/instrument/**`, + `src/easydiffraction/analysis/calculators/**`. Commit: + `Extend calculator support metadata with radiation probe` + +- [x] **P1.12 — Cross-engine verification pages + script wiring (§6)** + Add the `Verification` nav node (between Tutorials and + Command-Line) and a calculation-only `.py` comparison page (cryspy + ↔ crysfml) for the **first** supported combination + (constant-wavelength powder), with closeness metrics, overlay + plots, and metric-tolerance assertions. The remaining supported + combinations (time-of-flight powder, single crystal) are added + incrementally (issue 115). **Wire the new + `docs/docs/verification/` directory into the script-test runner** + — `tools/test_scripts.py` discovers only + `docs/docs/tutorials/*.py` today (lines 24-27) — and into the + notebook pipeline (`notebook-prepare` / `notebook-convert` / + `notebook-tests`, which target the tutorials dir), so the pages + are generated and exercised as regressions. Run + `pixi run notebook-prepare`. Files: `docs/mkdocs.yml`, + `docs/docs/verification/*.py` (+ generated `*.ipynb`), + `tools/test_scripts.py`, `pixi.toml`. Commit: + `Add cross-engine verification comparison pages and script wiring` + +- [x] **P1.13 — Per-experiment performance benchmarks (§7)** Add + `pytest-benchmark` (dev dep), `nightly`-marked benchmarks keyed by + `beam_mode × radiation_probe × engine`, and a `benchmarks` pixi + task emitting JSON as a CI artifact (data-repo history deferred). + Files: `pyproject.toml`, `pixi.toml`, `tests/benchmarks/**`. + Commit: `Add per-experiment performance benchmarks (nightly)` + +- [x] **P1.14 — Record cross-repository future work (§8 + deferred)** + Add prioritised entries to `docs/dev/issues/open.md` for the + nightly COD harness + results DB + pip-install acceptance job, + generative fuzzing, data-repo benchmark history, and + external-software comparison data. Confirm the ADR Deferred Work + covers them. Files: `docs/dev/issues/open.md`. Commit: + `Record cross-repo nightly harness and benchmarks as future work` + +- [x] **P1.15 — Promote ADR to accepted (§Change Discipline)** + `git mv docs/dev/adrs/suggestions/test-suite-and-validation.md docs/dev/adrs/accepted/`; + set its `## Status` to `Accepted.`; flip the + `docs/dev/adrs/index.md` row to `Accepted` with the `accepted/...` + link; fix any links that pointed at the `suggestions/` path + (`git grep -n`). Files: ADR file (moved), + `docs/dev/adrs/index.md`. Commit: + `Promote test-suite-and-validation ADR to accepted` + +- [x] **P1.16 — Phase 1 review gate (no code)** Confirm every box above + is `[x]`. Mark this step and commit the checklist update alone. + Commit: `Reach Phase 1 review gate` + +## Phase 2 verification + +Run after the Phase 1 review gate closes. Use the zsh-safe log-capture +pattern where output is needed. + +```shell +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 > /tmp/easydiffraction-integration.log 2>&1; integration_tests_exit_code=$?; tail -n 200 /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 200 /tmp/easydiffraction-script.log; exit $script_tests_exit_code +``` + +Phase 2 also: confirm `pixi run test-structure-check` passes after the +relocation; confirm the new `docs-build-strict`/`link-check`/ +`spell-check` tasks pass; resolve Open question 2 if `fail_under = 80` +fails. `pixi run fix` regenerates +`docs/dev/package-structure/{full,short}.md` — accept those. Leave +generated `docs/dev/benchmarking/*.csv` and tutorial project outputs +untracked unless explicitly asked. + +## Status checklist + +- [ ] Phase 1 complete (P1.1–P1.16) and reviewed via `/review-impl-1`. +- [ ] Phase 2 verification complete and reviewed via `/review-impl-2`. +- [ ] ADR promoted to `accepted/`. +- [ ] Cross-repository follow-ups recorded in `docs/dev/issues/open.md`. + +## Suggested Pull Request + +**Title:** Stronger, clearer test suite with cross-engine verification + +**Description:** This change makes EasyDiffraction's tests easier to +trust and easier to contribute to. It defines exactly which kind of test +belongs where (fast unit checks vs. slower engine tests), so the suite +stays quick day to day and runs the heavy checks on pull requests and +overnight. It fixes the long-standing red "patch" mark on pull requests +by correcting how coverage is reported, and raises the coverage target +while adding smarter tests that probe edge cases (negative, zero, and +out-of-range inputs), not just lines of code. It adds a new +**Verification** section to the documentation that calculates the same +diffraction pattern with each supported engine and shows, with clear +metrics and overlaid plots, how closely they agree. It also adds an +every-push documentation check (strict build, working links, spelling) +and a performance-benchmark suite. Larger overnight checks against many +real-world crystal files, and comparisons with external software such as +FullProf, are documented as planned follow-up work. diff --git a/docs/dev/testing-guide.md b/docs/dev/testing-guide.md new file mode 100644 index 000000000..b3f3bf7b4 --- /dev/null +++ b/docs/dev/testing-guide.md @@ -0,0 +1,74 @@ +# Testing Guide + +Practical placement rules for the easydiffraction test suite. The +rationale is recorded in the ADRs +[Test Strategy](adrs/accepted/test-strategy.md) and +[Test Suite and Validation Strategy](adrs/accepted/test-suite-and-validation.md). + +## Layers — what goes where + +A test belongs to the **lowest** layer whose constraints it can satisfy. + +| Layer | May use | Must NOT use | Speed | +| --------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------- | +| **unit** | one module under test; in-process logic; `tmp_path` | real calculation engine; network / `download_data()`; filesystem outside `tmp_path`; `sleep`; subprocess | sub-second | +| **functional** | several modules / a workflow; small bundled fixtures | real calculation engine; network / `download_data()` | seconds | +| **integration** | real engines, real fits, real downloaded data (the only layer allowed network and real backends) | — | slow | +| **script** | a full tutorial `.py` executed subprocess-isolated | — | slow | +| **notebook** | a generated `.ipynb` executed via `nbmake` | — | slow | + +Mocking a forbidden dependency (for example a mocked `download_data()`) +keeps a test in a lower layer **only when the mock is explicit**; an +accidental real call is a layer violation. + +The unit tree mirrors `src/` one-to-one. `tools/test_structure_check.py` +enforces this and is gated in CI (`lint-format.yml`); create the mirror +test file for a new module with `tools/gen_tests_scaffold.py`. + +## Where does this test go? + +1. Does it call a real calculation engine (cryspy / crysfml / pdffit) or + download data? → **integration**. +2. Does it run a whole tutorial `.py`? → **script** (and **notebook** + for the generated `.ipynb`). +3. Does it exercise several modules together, in-process, with bundled + fixtures only? → **functional**. +4. Otherwise — one module, in-process, fast? → **unit**, mirrored next + to its source module. + +## Cost tiers (orthogonal to layers) + +Tiers select _when_ a test runs in CI; they are independent of the +layer. + +| Tier | Marker | Runs on | +| ----------- | ---------------------- | ---------------------------------------------- | +| **fast** | (none — the default) | every push, every pull request, and nightly | +| **pr** | `@pytest.mark.pr` | pull requests and `develop`/`master` | +| **nightly** | `@pytest.mark.nightly` | the scheduled nightly job only (`nightly.yml`) | + +Integration tests are `pr`-tier by default — they are auto-marked in +`tests/integration/conftest.py` because they use real engines. Escalate +an individual test to the heaviest tier with `@pytest.mark.nightly`. + +CI marker selection: + +- feature-branch push: `-m "not pr and not nightly"` +- pull request + `develop`/`master`: `-m "not nightly"` +- nightly schedule: `-m nightly` + +## Numeric tolerances + +Prefer the shared comparison fixtures in `tests/conftest.py` over ad-hoc +per-test tolerances: one documented `rtol`/`atol` pair for intra-engine +numerics, and one (looser) pair for cross-engine comparison. + +## Input-domain coverage + +User input is validated at runtime through `core/validation.py` +(`AttributeSpec` pairs a `TypeValidator` with a content +`ValidatorBase`). Aim input-domain tests at the validators directly — +both that they accept the full valid domain and that they reject (or +fall back on) invalid values. Use `hypothesis` (deterministic profile) +for generative coverage and explicit parametrised tables for the +known-critical boundaries. diff --git a/docs/docs/assets/stylesheets/extra.css b/docs/docs/assets/stylesheets/extra.css index ae3c9bccd..2ab67a91f 100644 --- a/docs/docs/assets/stylesheets/extra.css +++ b/docs/docs/assets/stylesheets/extra.css @@ -182,13 +182,13 @@ label.md-nav__title[for="__drawer"] { padding-left: 0.5em; /* Default */ } -/* Change line height of the tabel cells */ +/* Change line height of the table cells */ .md-typeset td, .md-typeset th { line-height: 1.25 !important; } -/* Change vertical alignment of the icon inside the tabel cells */ +/* Change vertical alignment of the icon inside the table cells */ .md-typeset td .twemoji { vertical-align: sub !important; } diff --git a/docs/docs/verification/cross-engine-bragg-cwl.ipynb b/docs/docs/verification/cross-engine-bragg-cwl.ipynb new file mode 100644 index 000000000..dd4593e20 --- /dev/null +++ b/docs/docs/verification/cross-engine-bragg-cwl.ipynb @@ -0,0 +1,245 @@ +{ + "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": [ + "# Cross-engine verification — neutron powder, constant wavelength (Bragg)\n", + "\n", + "This page calculates the **same** diffraction pattern for one structure\n", + "and one experiment with each supported engine, **without any fitting**,\n", + "and reports how closely the engines agree. It doubles as a regression\n", + "check run by `pixi run script-tests`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "import easydiffraction as ed\n", + "from easydiffraction.analysis.calculators.support import calculator_support_matrix\n", + "from easydiffraction.analysis.fit_helpers.metrics import get_reliability_inputs" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Build the project (La0.5Ba0.5CoO3, HRPT)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "project = ed.Project()\n", + "project.structures.add_from_cif_path(ed.download_data(id=1, destination='data'))\n", + "project.experiments.add_from_cif_path(ed.download_data(id=2, destination='data'))\n", + "\n", + "experiment = project.experiments['hrpt']" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Engines to compare\n", + "\n", + "The calculator support matrix declares which engines can compute this\n", + "instrument condition (`cwl-pd`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "by_tag = {entry.instrument_tag: entry for entry in calculator_support_matrix()}\n", + "declared = sorted(c.value for c in by_tag['cwl-pd'].calculators)\n", + "print('Engines declared for cwl-pd:', declared)\n", + "\n", + "ENGINES = ['cryspy', 'crysfml']" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Calculate the pattern with each engine (no fitting)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "y_calc_by_engine = {}\n", + "for engine in ENGINES:\n", + " experiment.calculator.type = engine\n", + " assert experiment.calculator.type == engine\n", + " _, y_calc, _ = get_reliability_inputs(project.structures, [experiment])\n", + " y_calc_by_engine[engine] = np.asarray(y_calc, dtype=float)\n", + " # Per-engine measured-vs-calculated view (rendered in the docs build).\n", + " project.display.pattern(expt_name='hrpt')" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Closeness metrics between engines" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "a = y_calc_by_engine['cryspy']\n", + "b = y_calc_by_engine['crysfml']\n", + "\n", + "assert a.shape == b.shape, 'engines returned patterns of different length'\n", + "assert np.all(np.isfinite(a)), 'cryspy pattern has non-finite values'\n", + "assert np.all(np.isfinite(b)), 'crysfml pattern has non-finite values'\n", + "\n", + "rms = float(np.sqrt(np.mean((a - b) ** 2)))\n", + "norm = float(np.sqrt(np.mean(a**2)))\n", + "profile_diff_pct = 100.0 * rms / norm if norm else float('nan')\n", + "max_deviation = float(np.max(np.abs(a - b)))\n", + "intensity_ratio = float(a.sum() / b.sum()) if b.sum() else float('nan')\n", + "correlation = float(np.corrcoef(a, b)[0, 1])\n", + "\n", + "print(f'profile difference: {profile_diff_pct:.2f} %')\n", + "print(f'max point-wise deviation: {max_deviation:.4g}')\n", + "print(f'integrated-intensity ratio (cp/cf): {intensity_ratio:.4f}')\n", + "print(f'Pearson correlation: {correlation:.4f}')" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Overlay\n", + "\n", + "Both engines on one chart, in distinct colours and line styles." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "import plotly.graph_objects as go\n", + "\n", + "x = np.arange(a.size)\n", + "fig = go.Figure()\n", + "fig.add_scatter(x=x, y=a, mode='lines', name='cryspy', line={'color': 'royalblue'})\n", + "fig.add_scatter(x=x, y=b, mode='lines', name='crysfml', line={'color': 'crimson', 'dash': 'dot'})\n", + "fig.update_layout(\n", + " title='Calculated patterns: cryspy vs crysfml',\n", + " xaxis_title='point index',\n", + " yaxis_title='Icalc',\n", + ")\n", + "# Bare expression renders inline in the executed notebook; a no-op as a\n", + "# plain script, so `pixi run script-tests` stays headless-safe.\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## Regression assertions\n", + "\n", + "Explicit, named tolerances for each metric. cryspy and crysfml agree\n", + "very closely here (a first measurement gives ≈0.5% profile difference,\n", + "intensity ratio ≈1.00, correlation ≈1.00), so these bounds keep a\n", + "generous cross-platform margin while still catching a real regression.\n", + "They are tightened further once multi-platform nightly runs establish\n", + "the spread for each engine pair." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "MAX_PROFILE_DIFFERENCE_PCT = 10.0 # measured ≈0.5%\n", + "MAX_RELATIVE_DEVIATION = 1.0 # scale-sensitive; kept generous\n", + "MIN_INTENSITY_RATIO = 0.8 # measured ≈1.00\n", + "MAX_INTENSITY_RATIO = 1.25\n", + "MIN_CORRELATION = 0.99 # measured ≈1.00\n", + "\n", + "peak = float(np.max(np.abs(a)))\n", + "relative_deviation = max_deviation / peak if peak else float('nan')\n", + "\n", + "assert profile_diff_pct < MAX_PROFILE_DIFFERENCE_PCT, (\n", + " f'profile difference {profile_diff_pct:.2f}% exceeds {MAX_PROFILE_DIFFERENCE_PCT}%'\n", + ")\n", + "assert relative_deviation < MAX_RELATIVE_DEVIATION, (\n", + " f'relative max deviation {relative_deviation:.3f} exceeds {MAX_RELATIVE_DEVIATION}'\n", + ")\n", + "assert MIN_INTENSITY_RATIO < intensity_ratio < MAX_INTENSITY_RATIO, (\n", + " f'integrated-intensity ratio {intensity_ratio:.3f} outside '\n", + " f'[{MIN_INTENSITY_RATIO}, {MAX_INTENSITY_RATIO}]'\n", + ")\n", + "assert correlation > MIN_CORRELATION, (\n", + " f'cross-engine correlation {correlation:.4f} below {MIN_CORRELATION}'\n", + ")" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/verification/cross-engine-bragg-cwl.py b/docs/docs/verification/cross-engine-bragg-cwl.py new file mode 100644 index 000000000..55276d567 --- /dev/null +++ b/docs/docs/verification/cross-engine-bragg-cwl.py @@ -0,0 +1,128 @@ +# %% [markdown] +# # Cross-engine verification — neutron powder, constant wavelength (Bragg) +# +# This page calculates the **same** diffraction pattern for one structure +# and one experiment with each supported engine, **without any fitting**, +# and reports how closely the engines agree. It doubles as a regression +# check run by `pixi run script-tests`. + +# %% +import numpy as np + +import easydiffraction as ed +from easydiffraction.analysis.calculators.support import calculator_support_matrix +from easydiffraction.analysis.fit_helpers.metrics import get_reliability_inputs + +# %% [markdown] +# ## Build the project (La0.5Ba0.5CoO3, HRPT) + +# %% +project = ed.Project() +project.structures.add_from_cif_path(ed.download_data(id=1, destination='data')) +project.experiments.add_from_cif_path(ed.download_data(id=2, destination='data')) + +experiment = project.experiments['hrpt'] + +# %% [markdown] +# ## Engines to compare +# +# The calculator support matrix declares which engines can compute this +# instrument condition (`cwl-pd`). + +# %% +by_tag = {entry.instrument_tag: entry for entry in calculator_support_matrix()} +declared = sorted(c.value for c in by_tag['cwl-pd'].calculators) +print('Engines declared for cwl-pd:', declared) + +ENGINES = ['cryspy', 'crysfml'] + +# %% [markdown] +# ## Calculate the pattern with each engine (no fitting) + +# %% +y_calc_by_engine = {} +for engine in ENGINES: + experiment.calculator.type = engine + assert experiment.calculator.type == engine + _, y_calc, _ = get_reliability_inputs(project.structures, [experiment]) + y_calc_by_engine[engine] = np.asarray(y_calc, dtype=float) + # Per-engine measured-vs-calculated view (rendered in the docs build). + project.display.pattern(expt_name='hrpt') + +# %% [markdown] +# ## Closeness metrics between engines + +# %% +a = y_calc_by_engine['cryspy'] +b = y_calc_by_engine['crysfml'] + +assert a.shape == b.shape, 'engines returned patterns of different length' +assert np.all(np.isfinite(a)), 'cryspy pattern has non-finite values' +assert np.all(np.isfinite(b)), 'crysfml pattern has non-finite values' + +rms = float(np.sqrt(np.mean((a - b) ** 2))) +norm = float(np.sqrt(np.mean(a**2))) +profile_diff_pct = 100.0 * rms / norm if norm else float('nan') +max_deviation = float(np.max(np.abs(a - b))) +intensity_ratio = float(a.sum() / b.sum()) if b.sum() else float('nan') +correlation = float(np.corrcoef(a, b)[0, 1]) + +print(f'profile difference: {profile_diff_pct:.2f} %') +print(f'max point-wise deviation: {max_deviation:.4g}') +print(f'integrated-intensity ratio (cp/cf): {intensity_ratio:.4f}') +print(f'Pearson correlation: {correlation:.4f}') + +# %% [markdown] +# ## Overlay +# +# Both engines on one chart, in distinct colours and line styles. + +# %% +import plotly.graph_objects as go + +x = np.arange(a.size) +fig = go.Figure() +fig.add_scatter(x=x, y=a, mode='lines', name='cryspy', line={'color': 'royalblue'}) +fig.add_scatter(x=x, y=b, mode='lines', name='crysfml', line={'color': 'crimson', 'dash': 'dot'}) +fig.update_layout( + title='Calculated patterns: cryspy vs crysfml', + xaxis_title='point index', + yaxis_title='Icalc', +) +# Bare expression renders inline in the executed notebook; a no-op as a +# plain script, so `pixi run script-tests` stays headless-safe. +fig + +# %% [markdown] +# ## Regression assertions +# +# Explicit, named tolerances for each metric. cryspy and crysfml agree +# very closely here (a first measurement gives ≈0.5% profile difference, +# intensity ratio ≈1.00, correlation ≈1.00), so these bounds keep a +# generous cross-platform margin while still catching a real regression. +# They are tightened further once multi-platform nightly runs establish +# the spread for each engine pair. + +# %% +MAX_PROFILE_DIFFERENCE_PCT = 10.0 # measured ≈0.5% +MAX_RELATIVE_DEVIATION = 1.0 # scale-sensitive; kept generous +MIN_INTENSITY_RATIO = 0.8 # measured ≈1.00 +MAX_INTENSITY_RATIO = 1.25 +MIN_CORRELATION = 0.99 # measured ≈1.00 + +peak = float(np.max(np.abs(a))) +relative_deviation = max_deviation / peak if peak else float('nan') + +assert profile_diff_pct < MAX_PROFILE_DIFFERENCE_PCT, ( + f'profile difference {profile_diff_pct:.2f}% exceeds {MAX_PROFILE_DIFFERENCE_PCT}%' +) +assert relative_deviation < MAX_RELATIVE_DEVIATION, ( + f'relative max deviation {relative_deviation:.3f} exceeds {MAX_RELATIVE_DEVIATION}' +) +assert MIN_INTENSITY_RATIO < intensity_ratio < MAX_INTENSITY_RATIO, ( + f'integrated-intensity ratio {intensity_ratio:.3f} outside ' + f'[{MIN_INTENSITY_RATIO}, {MAX_INTENSITY_RATIO}]' +) +assert correlation > MIN_CORRELATION, ( + f'cross-engine correlation {correlation:.4f} below {MIN_CORRELATION}' +) diff --git a/docs/docs/verification/index.md b/docs/docs/verification/index.md new file mode 100644 index 000000000..c7497a0b7 --- /dev/null +++ b/docs/docs/verification/index.md @@ -0,0 +1,15 @@ +--- +icon: material/check-decagram +--- + +# :material-check-decagram: Verification + +This section compares EasyDiffraction's calculation engines against each +other (and, in future, against external software such as FullProf) on +the **same** input parameters, **without any fitting** — just calculated +diffraction patterns and clear closeness metrics. + +Each page also runs as a fast regression check +(`pixi run script-tests`), so cross-engine agreement is monitored over +time. Coverage grows to span every supported experiment and instrument +combination. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b7b7786fd..85a75d08c 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -243,6 +243,10 @@ nav: - Tb2TiO7 sg bumps-dream: tutorials/ed-22.ipynb - Workshops & Schools: - DMSC Summer School: tutorials/ed-13.ipynb + - Verification: + - Verification: verification/index.md + - Cross-engine: + - LBCO Bragg pd-neut-cwl: verification/cross-engine-bragg-cwl.ipynb - Command-Line: - Command-Line: cli/index.md - API Reference: diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 000000000..db6de881d --- /dev/null +++ b/lychee.toml @@ -0,0 +1,22 @@ +# lychee link-checker configuration. +# +# Fast every-push docs gate: verify local/relative links only. External +# URL checking is deferred to avoid flakiness from rate-limited sites +# (see docs/dev/issues/open.md issue 114). + +# Skip online links entirely — deterministic and fast, no network. +offline = true + +# Quieter CI logs. +no_progress = true + +# Directories never traversed for links: third-party/tooling caches and +# generated artifacts (tutorial notebooks and package-structure docs are +# generated; their sources are checked elsewhere). +exclude_path = [ + "node_modules", + ".pixi", + "tmp", + "docs/docs/tutorials", + "docs/dev/package-structure", +] diff --git a/pixi.lock b/pixi.lock index 59f1ad7ed..c366f7f60 100644 --- a/pixi.lock +++ b/pixi.lock @@ -60,6 +60,7 @@ environments: - 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/libxcb-1.17.0-h8a09558_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/lychee-0.24.2-he64ecbb_0.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 @@ -231,7 +232,9 @@ environments: - 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/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/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.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/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz @@ -246,6 +249,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl @@ -284,6 +288,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -326,11 +331,13 @@ 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/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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl @@ -504,6 +511,7 @@ environments: - 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.6-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lychee-0.24.2-h17e24d4_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 @@ -554,6 +562,8 @@ 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/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -567,6 +577,7 @@ 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/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl @@ -608,6 +619,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.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/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 @@ -652,9 +664,11 @@ environments: - 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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl @@ -816,6 +830,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_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.6-h4fa8253_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lychee-0.24.2-hb3eb754_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_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py314h5a2d7ad_0.conda @@ -869,6 +884,8 @@ 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/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -883,6 +900,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/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 @@ -925,6 +943,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -968,10 +987,12 @@ environments: - 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 + - pypi: https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/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 + - pypi: https://files.pythonhosted.org/packages/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/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 @@ -1045,6 +1066,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_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/lychee-0.24.2-he64ecbb_0.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 @@ -1211,7 +1233,9 @@ 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/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.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/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz @@ -1226,6 +1250,7 @@ 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/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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl @@ -1271,6 +1296,7 @@ environments: - 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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl @@ -1312,11 +1338,13 @@ 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/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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/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 @@ -1487,6 +1515,7 @@ environments: - 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.6-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lychee-0.24.2-h17e24d4_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 @@ -1540,7 +1569,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.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/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -1555,6 +1586,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/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 @@ -1592,6 +1624,7 @@ environments: - 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/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl @@ -1638,9 +1671,11 @@ 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/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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/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 @@ -1798,6 +1833,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_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.6-h4fa8253_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lychee-0.24.2-hb3eb754_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_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py312he06e257_0.conda @@ -1855,7 +1891,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-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/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.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/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -1869,6 +1907,7 @@ 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/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/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 @@ -1909,6 +1948,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -1954,10 +1994,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/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 @@ -2025,6 +2067,7 @@ environments: - 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/libxcb-1.17.0-h8a09558_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/lychee-0.24.2-he64ecbb_0.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 @@ -2196,7 +2239,9 @@ environments: - 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/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/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.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/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/eb/f9f1ded8e4db9638f9530c3782eb01f5ab04945f4cb9e597a51c203fa4c5/diffpy_pdffit2-1.6.0.tar.gz @@ -2211,6 +2256,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/46/b4/0887c88ddfaba1d7140ea335144eb904af97550786ee58bdb295ff10d255/crysfml-0.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl @@ -2249,6 +2295,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -2291,11 +2338,13 @@ 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/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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl @@ -2469,6 +2518,7 @@ environments: - 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.6-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lychee-0.24.2-h17e24d4_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 @@ -2519,6 +2569,8 @@ 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/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -2532,6 +2584,7 @@ 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/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl @@ -2573,6 +2626,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.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/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 @@ -2617,9 +2671,11 @@ environments: - 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/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.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/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/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl @@ -2781,6 +2837,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_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.6-h4fa8253_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lychee-0.24.2-hb3eb754_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_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py314h5a2d7ad_0.conda @@ -2834,6 +2891,8 @@ 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/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.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 - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl @@ -2848,6 +2907,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-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/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 @@ -2890,6 +2950,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/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -2933,10 +2994,12 @@ environments: - 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 + - pypi: https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/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 + - pypi: https://files.pythonhosted.org/packages/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-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/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 @@ -4335,6 +4398,19 @@ packages: purls: [] size: 63629 timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lychee-0.24.2-he64ecbb_0.conda + sha256: 3fd9108a9af3ad8f5124b9dc4ec4c0169e27e6379100374844b6a058ca8e8988 + md5: 5ef12daae06f2228dcb37c95a8db1881 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - openssl >=3.5.6,<4.0a0 + constrains: + - __glibc >=2.17 + license: Apache-2.0 OR MIT + purls: [] + size: 5789257 + timestamp: 1777662729855 - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py312h8a5da7c_1.conda sha256: 5f3aad1f3a685ed0b591faad335957dbdb1b73abfd6fc731a0d42718e0653b33 md5: 93a4752d42b12943a355b682ee43285b @@ -7180,6 +7256,18 @@ packages: purls: [] size: 284850 timestamp: 1779340584016 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lychee-0.24.2-h17e24d4_0.conda + sha256: f8d8397e8aa3077ebaeffdd18f7aaf0fce4188a3dbd08514ed85dc34acee4220 + md5: 0b13f105d1195e1b388706f8eb7f0a03 + depends: + - __osx >=11.0 + - openssl >=3.5.6,<4.0a0 + constrains: + - __osx >=11.0 + license: Apache-2.0 OR MIT + purls: [] + size: 5382476 + timestamp: 1777662876427 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py312h04c11ed_1.conda sha256: 330394fb9140995b29ae215a19fad46fcc6691bdd1b7654513d55a19aaa091c1 md5: 11d95ab83ef0a82cc2de12c1e0b47fe4 @@ -8193,6 +8281,18 @@ packages: purls: [] size: 347116 timestamp: 1779341186510 +- conda: https://conda.anaconda.org/conda-forge/win-64/lychee-0.24.2-hb3eb754_0.conda + sha256: 826077733e3c251831d7b7c9cdb27da4b661044363343c5614527a618cd94d40 + md5: f06076ddc8c0f46c64900f1c8b479325 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - openssl >=3.5.6,<4.0a0 + license: Apache-2.0 OR MIT + purls: [] + size: 6020917 + timestamp: 1777662847120 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda sha256: b744287a780211ac4595126ef96a44309c791f155d4724021ef99092bae4aace md5: a73298d225c7852f97403ca105d10a13 @@ -8775,10 +8875,12 @@ packages: - uncertainties - varname - build ; extra == 'dev' + - codespell ; extra == 'dev' - copier ; extra == 'dev' - docstripy ; extra == 'dev' - format-docstring ; extra == 'dev' - gitpython ; extra == 'dev' + - hypothesis ; extra == 'dev' - interrogate ; extra == 'dev' - jinja2 ; extra == 'dev' - jupyterquiz ; extra == 'dev' @@ -8797,7 +8899,9 @@ packages: - pre-commit ; extra == 'dev' - pydoclint ; extra == 'dev' - pytest ; extra == 'dev' + - pytest-benchmark ; extra == 'dev' - pytest-cov ; extra == 'dev' + - pytest-randomly ; extra == 'dev' - pytest-xdist ; extra == 'dev' - pyyaml ; extra == 'dev' - radon ; extra == 'dev' @@ -9648,6 +9752,10 @@ packages: - griffelib>=2.0 - typing-extensions>=4.0 ; python_full_version < '3.11' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + name: sortedcontainers + version: 2.4.0 + sha256: a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 - 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 name: matplotlib version: 3.10.9 @@ -9699,6 +9807,19 @@ packages: - prettytable - ply - numpy +- pypi: https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl + name: pytest-benchmark + version: 5.2.3 + sha256: bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803 + requires_dist: + - pytest>=8.1 + - py-cpuinfo + - aspectlib ; extra == 'aspect' + - pygal ; extra == 'histogram' + - pygaljs ; extra == 'histogram' + - setuptools ; extra == 'histogram' + - elasticsearch ; extra == 'elasticsearch' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl name: distlib version: 0.4.0 @@ -10055,6 +10176,29 @@ packages: - setuptools-scm>=7,<10 ; extra == 'dev' - setuptools>=64 ; extra == 'dev' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl + name: codespell + version: 2.4.2 + sha256: 97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886 + requires_dist: + - build ; extra == 'dev' + - chardet ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-dependency ; extra == 'dev' + - pygments ; extra == 'dev' + - ruff ; extra == 'dev' + - tomli ; extra == 'dev' + - twine ; extra == 'dev' + - chardet ; extra == 'hard-encoding-detection' + - tomli ; python_full_version < '3.11' and extra == 'toml' + - chardet>=5.1.0 ; extra == 'types' + - mypy ; extra == 'types' + - pytest ; extra == 'types' + - pytest-cov ; extra == 'types' + - pytest-dependency ; extra == 'types' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl name: mpmath version: 1.3.0 @@ -11359,6 +11503,13 @@ packages: version: 1.5.0 sha256: ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl + name: pytest-randomly + version: 4.1.0 + sha256: f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9 + requires_dist: + - pytest + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl name: aiohttp version: 3.13.5 @@ -12653,6 +12804,10 @@ packages: version: 2.4.6 sha256: b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl + name: py-cpuinfo + version: 9.0.0 + sha256: 859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5 - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl name: multidict version: 6.7.1 @@ -12714,6 +12869,49 @@ packages: version: 0.7.5 sha256: a1fdb6f72006495b5119e3a8bb5c3185efa708b785bd4a5ce4397ef7abb3fec7 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/ec/6e/e735f27ac1a530a4cd0a31cd970ec495a3a11830fdc5d281cc292593b330/hypothesis-6.155.2-py3-none-any.whl + name: hypothesis + version: 6.155.2 + sha256: c85ce6dcd630a90ce501f1d1dd1bc84b97f5649ca8a27e134c8cbf5aa480b1a5 + requires_dist: + - exceptiongroup>=1.0.0 ; python_full_version < '3.11' + - sortedcontainers>=2.1.0,<3.0.0 + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - black>=20.8b0 ; extra == 'ghostwriter' + - pytz>=2014.1 ; extra == 'pytz' + - python-dateutil>=1.4 ; extra == 'dateutil' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.21.6 ; extra == 'numpy' + - pandas>=1.1 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - redis>=3.0.0 ; extra == 'redis' + - hypothesis-crosshair>=0.0.28 ; extra == 'crosshair' + - crosshair-tool>=0.0.106 ; extra == 'crosshair' + - tzdata>=2026.2 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + - django>=5.2 ; extra == 'django' + - watchdog>=4.0.0 ; extra == 'watchdog' + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.106 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.28 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.21.6 ; extra == 'all' + - pandas>=1.1 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.2 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl name: prettytable version: 3.17.0 diff --git a/pixi.toml b/pixi.toml index 928c8bb22..1baf8cf6e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -54,6 +54,7 @@ ipython = '*' # Interactive Python shell pixi-kernel = '*' # Pixi Jupyter kernel gsl = '*' # GNU Scientific Library; required for diffpy.pdffit2 tectonic = '*' # LaTeX engine for PDF report generation +lychee = '*' # Link checker for documentation [feature.dev.pypi-dependencies] pip = '*' @@ -106,6 +107,12 @@ 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' +# Run nightly-tier tests across the suite (benchmarks have their own task +# because pytest-benchmark does not run under xdist). +nightly-tests = 'python -m pytest tests/ -m nightly --ignore=tests/benchmarks --color=yes -n auto -v' +# Performance benchmarks, per experiment type; writes benchmark.json for +# the nightly CI artifact. Informational (no regression gate yet). +benchmarks = 'python -m pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark.json --color=yes -v' # Remove previously saved tutorial output projects (projects/ed_*) so the # tutorial-output checks cannot pass against a stale artifact from an earlier @@ -114,7 +121,7 @@ clean-tutorial-projects = { cmd = 'python tools/clean_tutorial_projects.py', env 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 = [ +notebook-tests = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ docs/docs/verification/ --nbmake-timeout=1200 --color=yes -n auto -v', depends-on = [ 'clean-tutorial-projects', ], env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } @@ -139,19 +146,31 @@ test-all = { depends-on = [ 'script-tests', ] } +# Full local gate before opening a PR: every static check, unit coverage, +# all tests, tutorial execution + output verification (as scripts AND +# notebooks), and a strict documentation build (which validates the built +# site's nav/links). Steps run sequentially because the tutorial tasks +# share tmp/tutorials/projects/. Slow but comprehensive — run `pixi run +# fix` first to auto-format. +all = 'pixi run check && pixi run unit-tests-coverage && pixi run functional-tests && pixi run integration-tests && pixi run script-tests-checked && pixi run notebook-tests-checked && pixi run docs-build-local' + ########### # ✔️ Checks ########### pyproject-check = 'python -m validate_pyproject pyproject.toml' docstring-lint-check = 'pydoclint --quiet src/' -notebook-lint-check = 'nbqa ruff docs/docs/tutorials/' -py-lint-check = 'ruff check src/ tests/ docs/docs/tutorials/' -py-format-check = 'ruff format --check src/ tests/ docs/docs/tutorials/' +notebook-lint-check = 'nbqa ruff docs/docs/tutorials/ docs/docs/verification/' +py-lint-check = 'ruff check src/ tests/ docs/docs/tutorials/ docs/docs/verification/' +py-format-check = 'ruff format --check src/ tests/ docs/docs/tutorials/ docs/docs/verification/' nonpy-format-check = 'npx prettier --list-different --config=prettierrc.toml --ignore-unknown .' nonpy-format-check-modified = 'python tools/nonpy_prettier_modified.py' test-structure-check = 'python tools/test_structure_check.py' - +# Spelling over docs and source (config in [tool.codespell]). +spell-check = 'codespell docs src tools README.md CONTRIBUTING.md' +# Local/relative documentation link integrity (offline; external URL +# checking is deferred, see issue 114). Config in lychee.toml. +link-check = 'lychee --config lychee.toml docs/docs README.md CONTRIBUTING.md' check = 'pre-commit run --hook-stage manual --all-files' ########## @@ -160,10 +179,10 @@ check = 'pre-commit run --hook-stage manual --all-files' docstring-transform = 'pixi run docstripy src/ -s=numpy -w' docstring-format-fix = 'format-docstring src/' -notebook-lint-fix = 'nbqa ruff --fix docs/docs/tutorials/' -py-lint-fix = 'ruff check --fix src/ tests/ docs/docs/tutorials/' -py-lint-fix-unsafe = 'ruff check --fix --unsafe-fixes src/ tests/ docs/docs/tutorials/' -py-format-fix = 'ruff format src/ tests/ docs/docs/tutorials/' +notebook-lint-fix = 'nbqa ruff --fix docs/docs/tutorials/ docs/docs/verification/' +py-lint-fix = 'ruff check --fix src/ tests/ docs/docs/tutorials/ docs/docs/verification/' +py-lint-fix-unsafe = 'ruff check --fix --unsafe-fixes src/ tests/ docs/docs/tutorials/ docs/docs/verification/' +py-format-fix = 'ruff format src/ tests/ docs/docs/tutorials/ docs/docs/verification/' nonpy-format-fix = 'npx prettier --write --list-different --config=prettierrc.toml --ignore-unknown .' nonpy-format-fix-modified = 'python tools/nonpy_prettier_modified.py --write' update-package-diagrams = 'python tools/generate_package_docs.py' @@ -200,11 +219,7 @@ functional-tests-coverage = 'pixi run functional-tests --cov=src/easydiffraction integration-tests-coverage = 'pixi run integration-tests --cov=src/easydiffraction --cov-report=term-missing' docstring-coverage = 'interrogate -c pyproject.toml src/easydiffraction' -cov = { depends-on = [ - 'docstring-coverage', - 'unit-tests-coverage', - 'integration-tests-coverage', -] } +cov = { depends-on = ['docstring-coverage', 'unit-tests-coverage'] } ######################## # 📓 Notebook Management @@ -215,11 +230,11 @@ tutorial = { cmd = 'python', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutori tutorial-benchmarks = { cmd = 'python tools/benchmark_tutorials.py', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } jupyter = { cmd = 'jupyter', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials' } } -notebook-convert = 'jupytext docs/docs/tutorials/*.py --from py:percent --to ipynb' -notebook-strip = 'nbstripout docs/docs/tutorials/*.ipynb' -notebook-tweak = 'python tools/tweak_notebooks.py docs/docs/tutorials/' -notebook-exec = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --overwrite --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials', EASYDIFFRACTION_FIGURE_EMBED_MODE = 'shared' } } -notebook-exec-ci = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = '.', EASYDIFFRACTION_FIGURE_EMBED_MODE = 'shared' } } +notebook-convert = 'jupytext docs/docs/tutorials/*.py docs/docs/verification/*.py --from py:percent --to ipynb' +notebook-strip = 'nbstripout docs/docs/tutorials/*.ipynb docs/docs/verification/*.ipynb' +notebook-tweak = 'python tools/tweak_notebooks.py docs/docs/tutorials/ docs/docs/verification/' +notebook-exec = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ docs/docs/verification/ --overwrite --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = 'tmp/tutorials', EASYDIFFRACTION_FIGURE_EMBED_MODE = 'shared' } } +notebook-exec-ci = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ docs/docs/verification/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v', env = { EASYDIFFRACTION_ARTIFACT_ROOT = '.', EASYDIFFRACTION_FIGURE_EMBED_MODE = 'shared' } } notebook-prepare = { depends-on = [ 'notebook-convert', @@ -240,9 +255,14 @@ docs-serve = { cmd = 'pixi run docs-pre serve -f docs/mkdocs.yml', depends-on = 'docs-sync-vendored-js', ] } docs-serve-dirty = 'pixi run docs-serve --dirty' -docs-build = { cmd = 'pixi run docs-pre build -f docs/mkdocs.yml', depends-on = [ +# Strict build (fails on broken nav / internal references); used for the +# deploy and the CI docs check. Tutorials are not executed (mkdocs-jupyter +# execute: false). docs-serve is intentionally left non-strict. +docs-build = { cmd = 'pixi run docs-pre build --strict -f docs/mkdocs.yml', depends-on = [ 'docs-sync-vendored-js', ] } +# Inherits --strict; --no-directory-urls makes the built site browsable +# from disk (used by `all` so you can inspect it after verifying). docs-build-local = 'pixi run docs-build --no-directory-urls' docs-deploy-pre = 'mike deploy -F docs/mkdocs.yml --push --branch gh-pages --update-aliases --alias-type redirect' diff --git a/pyproject.toml b/pyproject.toml index 4ef0ad73f..922eea94a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,8 +63,12 @@ dev = [ 'pytest', # Testing 'pytest-cov', # Test coverage 'pytest-xdist', # Enable parallel testing + 'pytest-randomly', # Randomise test order to catch ordering bugs + 'pytest-benchmark', # Performance benchmarking (nightly) + 'hypothesis', # Property-based / input-domain testing 'ruff', # Linting and formatting code 'radon', # Code complexity and maintainability + 'codespell', # Spell checking docs and source 'validate-pyproject[all]', # Validate pyproject.toml 'versioningit', # Automatic versioning from git tags 'jupytext', # Jupyter notebook text format support @@ -183,7 +187,7 @@ omit = [ [tool.coverage.report] show_missing = true # Show missing lines skip_covered = false # Skip files with 100% coverage in the report -fail_under = 65 # Minimum coverage percentage to pass +fail_under = 88 # Minimum coverage percentage to pass (ramp toward 90-95) ########################## # Configuration for pytest @@ -194,7 +198,10 @@ fail_under = 65 # Minimum coverage percentage to pass [tool.pytest.ini_options] addopts = '--import-mode=importlib' -markers = ['fast: mark test as fast (should be run on every push)'] +markers = [ + 'pr: heavier test; runs on pull requests and develop/master, not feature-branch pushes', + 'nightly: very expensive test; runs only on the scheduled nightly job', +] testpaths = ['tests'] filterwarnings = [ # TEMPRORARY: Suppress some warnings @@ -207,6 +214,18 @@ filterwarnings = [ "ignore:'diffpy\\.structure\\.Structure\\.writeStr':DeprecationWarning", ] +############################## +# Configuration for codespell +############################## + +# 'codespell' -- Spell checker for docs and source comments/docstrings. +# https://github.com/codespell-project/codespell +[tool.codespell] +skip = '*.ipynb,*.lock,*.svg,*.min.js,*.json.gz,*/vendor/*,*/_vendored/*,docs/dev/package-structure/*,docs/site/*,docs/overrides/*,*/structure/assets/*,node_modules,.pixi' +# British spelling and a crystallographic abbreviation (B/U) flagged as +# typos; element symbols live in skipped vendored files. +ignore-words-list = 'pre-emptively,bu' + ######################## # Configuration for ruff ######################## @@ -361,6 +380,14 @@ ignore = [ 'docs/docs/tutorials/**' = [ 'E402', # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ ] +# Verification pages are notebook sources too: allow mid-cell imports, +# printed metric values, and a trailing bare expression (figure render). +'docs/docs/verification/**' = [ + 'E402', # module-import-not-at-top-of-file + 'T201', # print (metric values shown in the rendered notebook) + 'B018', # useless-expression (trailing `fig` renders in the notebook) + 'S101', # assert (the regression checks in the verification script) +] # Intentional terminal rendering: these write raw/ASCII output that # `Console.print` would garble, so `print` is deliberate here. 'src/easydiffraction/display/plotters/ascii.py' = [ diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 128bb72e9..e16d55c83 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -167,6 +167,7 @@ class AnalysisDisplay: """ def __init__(self, analysis: Analysis) -> None: + """Bind the display helper to its analysis section.""" self._analysis = analysis def help(self) -> None: @@ -452,6 +453,8 @@ def as_cif(self) -> None: class _AnalysisOwnerAccessorsMixin: + """Accessors for the analysis section's owned collaborators.""" + @property def project(self) -> object: """Project that owns this analysis section.""" @@ -479,6 +482,7 @@ def fitter(self) -> Fitter: @fitter.setter def fitter(self, value: Fitter) -> None: + """Set the fitting engine used by this analysis object.""" self._fitter = value @property @@ -490,11 +494,14 @@ def fit_results(self) -> object | None: @fit_results.setter def fit_results(self, value: object | None) -> None: + """Store the latest fit results on the analysis and fitter.""" self._fit_results = value self._fitter.results = value class _AnalysisPersistedCategoryAccessorsMixin: + """Accessors for the analysis section's persisted categories.""" + @property def fit_parameters(self) -> FitParameters: """Persisted fit-parameter control snapshots.""" diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index ff4c33c75..2670f89e4 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -65,6 +65,7 @@ def name(self) -> str: return 'cryspy' def __init__(self) -> None: + """Initialize the calculator with empty cryspy caches.""" super().__init__() self._cryspy_dicts: dict[str, dict[str, Any]] = {} self._cached_peak_types: dict[str, str] = {} @@ -325,6 +326,12 @@ def last_powder_refln_records( def _powder_refln_core_arrays( phase_block: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """ + Extract HKL indices, sin(theta)/lambda and structure factors. + + Returns ``None`` when the phase block lacks the required arrays + or the HKL indices do not have the expected number of rows. + """ try: indices = np.asarray(phase_block['index_hkl'], dtype=int) sin_theta_over_lambda = np.asarray(phase_block['sthovl'], dtype=float) @@ -347,6 +354,12 @@ def _powder_refln_d_spacing( phase_block: dict[str, Any], sin_theta_over_lambda: np.ndarray, ) -> np.ndarray: + """ + Return d-spacings, deriving them from sin(theta)/lambda. + + Uses the ``d_hkl`` array from the phase block when present and + otherwise converts the supplied sin(theta)/lambda values. + """ d_spacing_raw = phase_block.get('d_hkl') if d_spacing_raw is None: return np.asarray( @@ -360,6 +373,13 @@ def _powder_refln_x_values( phase_block: dict[str, Any], beam_mode: BeamModeEnum, ) -> np.ndarray | None: + """ + Return reflection x positions for the given beam mode. + + Reads two-theta (converted to degrees) for constant wavelength + and time-of-flight values otherwise, returning ``None`` when no + values are available. + """ x_values = None if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: x_raw = phase_block.get('ttheta_hkl') @@ -382,6 +402,12 @@ def _powder_refln_record( hkl: tuple[int, int, int], values: tuple[float, float, float, float, float], ) -> PowderReflnRecord: + """ + Build a single powder reflection record. + + Stores the x position as two-theta for constant wavelength and + as time-of-flight for the time-of-flight beam mode. + """ index_h, index_k, index_l = hkl sin_theta_over_lambda, d_spacing, x_value, f_calc, f_squared_calc = values x_kwargs = {'two_theta': float(x_value)} @@ -562,6 +588,8 @@ def _update_aniso_beta( recip_params, _ = calc_reciprocal_by_unit_cell_parameters(cell_params) class _CellLike: + """Adapter exposing reciprocal cell lengths to cryspy.""" + reciprocal_length_a = recip_params[0] reciprocal_length_b = recip_params[1] reciprocal_length_c = recip_params[2] diff --git a/src/easydiffraction/analysis/calculators/support.py b/src/easydiffraction/analysis/calculators/support.py new file mode 100644 index 000000000..a24e7742b --- /dev/null +++ b/src/easydiffraction/analysis/calculators/support.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Calculator support matrix. + +Aggregates the per-instrument ``Compatibility`` and +``CalculatorSupport`` metadata declared on the registered instrument +categories into a single queryable matrix: which calculation engines can +compute which experiment conditions. Used by the Verification +documentation to enumerate comparable engine x condition combinations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +# Importing the instrument package registers every concrete instrument +# category, so the support matrix below is complete regardless of import +# order. +import easydiffraction.datablocks.experiment.categories.instrument # noqa: F401 +from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory + +if TYPE_CHECKING: + from easydiffraction.core.metadata import Compatibility + + +@dataclass(frozen=True) +class SupportEntry: + """ + One instrument condition and the engines that can compute it. + + Attributes + ---------- + instrument_tag : str + The instrument category tag (for example ``'cwl-pd'``). + description : str + One-line human-readable description of the instrument. + compatibility : Compatibility + The experimental conditions (sample_form x scattering_type x + beam_mode x radiation_probe) the instrument supports. + calculators : frozenset + The ``CalculatorEnum`` engines declared able to handle it. + """ + + instrument_tag: str + description: str + compatibility: Compatibility + calculators: frozenset + + +def calculator_support_matrix() -> list[SupportEntry]: + """ + Return the engine x experiment-condition support matrix. + + One entry per registered instrument category, pairing its + ``Compatibility`` with the calculators declared able to handle it. + + Returns + ------- + list[SupportEntry] + One entry per registered instrument category. + """ + return [ + SupportEntry( + instrument_tag=klass.type_info.tag, + description=klass.type_info.description, + compatibility=klass.compatibility, + calculators=frozenset(klass.calculator_support.calculators), + ) + for klass in InstrumentFactory._supported_map().values() + ] diff --git a/src/easydiffraction/analysis/categories/aliases/default.py b/src/easydiffraction/analysis/categories/aliases/default.py index 5e5f5f5b9..1054aacbb 100644 --- a/src/easydiffraction/analysis/categories/aliases/default.py +++ b/src/easydiffraction/analysis/categories/aliases/default.py @@ -34,6 +34,7 @@ class Alias(CategoryItem): _category_entry_name = 'label' def __init__(self) -> None: + """Initialize the alias descriptors and parameter reference.""" super().__init__() self._label = StringDescriptor( @@ -82,6 +83,7 @@ def label(self) -> StringDescriptor: @label.setter def label(self, value: str) -> None: + """Set the alias label value.""" self._label.value = value @property diff --git a/src/easydiffraction/analysis/categories/constraints/default.py b/src/easydiffraction/analysis/categories/constraints/default.py index dc64c1776..f9625b1ea 100644 --- a/src/easydiffraction/analysis/categories/constraints/default.py +++ b/src/easydiffraction/analysis/categories/constraints/default.py @@ -30,6 +30,7 @@ class Constraint(CategoryItem): _category_entry_name = 'id' def __init__(self) -> None: + """Initialize the constraint id and expression descriptors.""" super().__init__() self._id = StringDescriptor( @@ -68,6 +69,7 @@ def id(self) -> StringDescriptor: @id.setter def id(self, value: str) -> None: + """Set the constraint identifier value.""" self._id.value = value @property @@ -82,6 +84,7 @@ def expression(self) -> StringDescriptor: @expression.setter def expression(self, value: str) -> None: + """Set the constraint equation value.""" self._expression.value = value @property diff --git a/src/easydiffraction/analysis/categories/fit_parameter_correlations/default.py b/src/easydiffraction/analysis/categories/fit_parameter_correlations/default.py index eb0b1260a..eb60a62ca 100644 --- a/src/easydiffraction/analysis/categories/fit_parameter_correlations/default.py +++ b/src/easydiffraction/analysis/categories/fit_parameter_correlations/default.py @@ -37,6 +37,7 @@ class FitParameterCorrelationItem(CategoryItem): _category_entry_name = 'id' def __init__(self) -> None: + """Initialize the persisted correlation-row descriptors.""" super().__init__() self._id = StringDescriptor( name='id', @@ -137,6 +138,7 @@ class FitParameterCorrelations(CategoryCollection): ) def __init__(self) -> None: + """Create an empty fit-parameter correlations collection.""" super().__init__(item_type=FitParameterCorrelationItem) def create( diff --git a/src/easydiffraction/analysis/categories/fit_parameters/default.py b/src/easydiffraction/analysis/categories/fit_parameters/default.py index 9db1ae05a..b95654ac7 100644 --- a/src/easydiffraction/analysis/categories/fit_parameters/default.py +++ b/src/easydiffraction/analysis/categories/fit_parameters/default.py @@ -49,6 +49,7 @@ class FitParameterItem(CategoryItem): ) def __init__(self) -> None: + """Initialize the persisted fit-parameter descriptors.""" super().__init__() self._param_unique_name = StringDescriptor( name='param_unique_name', @@ -354,6 +355,7 @@ class FitParameters(CategoryCollection): ) def __init__(self) -> None: + """Create an empty fit-parameters collection.""" super().__init__(item_type=FitParameterItem) def _include_posterior_cif_descriptors(self) -> bool: diff --git a/src/easydiffraction/analysis/categories/fit_result/base.py b/src/easydiffraction/analysis/categories/fit_result/base.py index 90668aa50..e4765000c 100644 --- a/src/easydiffraction/analysis/categories/fit_result/base.py +++ b/src/easydiffraction/analysis/categories/fit_result/base.py @@ -48,6 +48,7 @@ class FitResultBase(CategoryItem): ) def __init__(self) -> None: + """Initialize the common persisted fit-result descriptors.""" super().__init__() self._result_kind = EnumDescriptor( name='result_kind', diff --git a/src/easydiffraction/analysis/categories/fit_result/bayesian.py b/src/easydiffraction/analysis/categories/fit_result/bayesian.py index 0cc5324eb..cb864cc15 100644 --- a/src/easydiffraction/analysis/categories/fit_result/bayesian.py +++ b/src/easydiffraction/analysis/categories/fit_result/bayesian.py @@ -45,6 +45,7 @@ class BayesianFitResult(FitResultBase): _expected_descriptor_names: ClassVar[tuple[str, ...]] = _result_descriptor_names def __init__(self) -> None: + """Initialize the Bayesian fit-result descriptors.""" super().__init__() self._point_estimate_name = self._point_estimate_name_descriptor() self._sampler_completed = self._sampler_completed_descriptor() diff --git a/src/easydiffraction/analysis/categories/fit_result/lsq.py b/src/easydiffraction/analysis/categories/fit_result/lsq.py index 72c2e5289..5ded1ff01 100644 --- a/src/easydiffraction/analysis/categories/fit_result/lsq.py +++ b/src/easydiffraction/analysis/categories/fit_result/lsq.py @@ -28,6 +28,7 @@ def objective_name(self) -> StringDescriptor: return self._objective_name def _set_objective_name(self, value: str | None) -> None: + """Set the persisted objective function name.""" self._objective_name.value = value @property @@ -36,6 +37,7 @@ def objective_value(self) -> NumericDescriptor: return self._objective_value def _set_objective_value(self, value: float | None) -> None: + """Set the persisted objective value.""" self._objective_value.value = value @property @@ -46,6 +48,7 @@ def n_data_points(self) -> NumericDescriptor: return self._n_data_points def _set_n_data_points(self, value: float | None) -> None: + """Set the persisted number of data points.""" self._n_data_points.value = value @property @@ -54,6 +57,7 @@ def n_parameters(self) -> NumericDescriptor: return self._n_parameters def _set_n_parameters(self, value: float | None) -> None: + """Set the persisted number of parameters.""" self._n_parameters.value = value @property @@ -64,6 +68,7 @@ def n_free_parameters(self) -> NumericDescriptor: return self._n_free_parameters def _set_n_free_parameters(self, value: float | None) -> None: + """Set the persisted number of free parameters.""" self._n_free_parameters.value = value @property @@ -72,6 +77,7 @@ def degrees_of_freedom(self) -> NumericDescriptor: return self._degrees_of_freedom def _set_degrees_of_freedom(self, value: float | None) -> None: + """Set the persisted degrees of freedom.""" self._degrees_of_freedom.value = value @property @@ -80,6 +86,7 @@ def covariance_available(self) -> BoolDescriptor: return self._covariance_available def _set_covariance_available(self, *, value: bool | None) -> None: + """Set whether deterministic covariance was available.""" self._covariance_available.value = value @property @@ -88,6 +95,7 @@ def correlation_available(self) -> BoolDescriptor: return self._correlation_available def _set_correlation_available(self, *, value: bool | None) -> None: + """Set whether deterministic correlations were available.""" self._correlation_available.value = value @property @@ -96,6 +104,7 @@ def exit_reason(self) -> StringDescriptor: return self._exit_reason def _set_exit_reason(self, value: str | None) -> None: + """Set the persisted backend exit reason.""" self._exit_reason.value = value @@ -108,6 +117,7 @@ def r_factor_all(self) -> NumericDescriptor: return self._r_factor_all def _set_r_factor_all(self, value: float | None) -> None: + """Set the R factor for all observed data.""" self._r_factor_all.value = value @property @@ -116,6 +126,7 @@ def wr_factor_all(self) -> NumericDescriptor: return self._wr_factor_all def _set_wr_factor_all(self, value: float | None) -> None: + """Set the weighted R factor for all observed data.""" self._wr_factor_all.value = value @property @@ -124,6 +135,7 @@ def r_factor_gt(self) -> NumericDescriptor: return self._r_factor_gt def _set_r_factor_gt(self, value: float | None) -> None: + """Set the R factor for observations above the threshold.""" self._r_factor_gt.value = value @property @@ -132,6 +144,7 @@ def wr_factor_gt(self) -> NumericDescriptor: return self._wr_factor_gt def _set_wr_factor_gt(self, value: float | None) -> None: + """Set the weighted R factor above the threshold.""" self._wr_factor_gt.value = value @property @@ -140,6 +153,7 @@ def threshold_expression(self) -> StringDescriptor: return self._threshold_expression def _set_threshold_expression(self, value: str | None) -> None: + """Set the observed-reflection threshold expression.""" self._threshold_expression.value = value @property @@ -148,6 +162,7 @@ def number_reflns_total(self) -> NumericDescriptor: return self._number_reflns_total def _set_number_reflns_total(self, value: float | None) -> None: + """Set the total number of reflections in the fit.""" self._number_reflns_total.value = value @property @@ -156,6 +171,7 @@ def number_reflns_gt(self) -> NumericDescriptor: return self._number_reflns_gt def _set_number_reflns_gt(self, value: float | None) -> None: + """Set the number of reflections above the threshold.""" self._number_reflns_gt.value = value @@ -168,6 +184,7 @@ def prof_r_factor(self) -> NumericDescriptor: return self._prof_r_factor def _set_prof_r_factor(self, value: float | None) -> None: + """Set the profile R factor for powder fits.""" self._prof_r_factor.value = value @property @@ -176,6 +193,7 @@ def prof_wr_factor(self) -> NumericDescriptor: return self._prof_wr_factor def _set_prof_wr_factor(self, value: float | None) -> None: + """Set the weighted profile R factor for powder fits.""" self._prof_wr_factor.value = value @property @@ -184,6 +202,7 @@ def prof_wr_expected(self) -> NumericDescriptor: return self._prof_wr_expected def _set_prof_wr_expected(self, value: float | None) -> None: + """Set the expected weighted profile R factor (powder).""" self._prof_wr_expected.value = value @property @@ -192,6 +211,7 @@ def number_restraints(self) -> NumericDescriptor: return self._number_restraints def _set_number_restraints(self, value: float | None) -> None: + """Set the number of restraints used in the fit.""" self._number_restraints.value = value @property @@ -200,6 +220,7 @@ def number_constraints(self) -> NumericDescriptor: return self._number_constraints def _set_number_constraints(self, value: float | None) -> None: + """Set the number of constraints used in the fit.""" self._number_constraints.value = value @property @@ -208,6 +229,7 @@ def shift_over_su_max(self) -> NumericDescriptor: return self._shift_over_su_max def _set_shift_over_su_max(self, value: float | None) -> None: + """Set the maximum absolute parameter shift divided by s.u.""" self._shift_over_su_max.value = value @property @@ -216,6 +238,7 @@ def shift_over_su_mean(self) -> NumericDescriptor: return self._shift_over_su_mean def _set_shift_over_su_mean(self, value: float | None) -> None: + """Set the mean absolute parameter shift divided by s.u.""" self._shift_over_su_mean.value = value @property @@ -224,6 +247,7 @@ def profile_function(self) -> StringDescriptor: return self._profile_function def _set_profile_function(self, value: str | None) -> None: + """Set the active profile function names.""" self._profile_function.value = value @property @@ -232,6 +256,7 @@ def background_function(self) -> StringDescriptor: return self._background_function def _set_background_function(self, value: str | None) -> None: + """Set the active background function names.""" self._background_function.value = value @@ -310,6 +335,7 @@ class LeastSquaresFitResult( ) def __init__(self) -> None: + """Initialize the least-squares fit-result descriptors.""" super().__init__() self._objective_name = self._string_result_descriptor( 'objective_name', diff --git a/src/easydiffraction/analysis/categories/fitting_mode/default.py b/src/easydiffraction/analysis/categories/fitting_mode/default.py index 4eeb59cfc..1ec612eea 100644 --- a/src/easydiffraction/analysis/categories/fitting_mode/default.py +++ b/src/easydiffraction/analysis/categories/fitting_mode/default.py @@ -30,6 +30,7 @@ class FittingMode(CategoryItem, SwitchableCategoryBase): ) def __init__(self) -> None: + """Initialize the fitting-mode type descriptor.""" super().__init__() self._type = StringDescriptor( diff --git a/src/easydiffraction/analysis/categories/joint_fit/default.py b/src/easydiffraction/analysis/categories/joint_fit/default.py index fbc37e2c2..eceafa4aa 100644 --- a/src/easydiffraction/analysis/categories/joint_fit/default.py +++ b/src/easydiffraction/analysis/categories/joint_fit/default.py @@ -28,6 +28,7 @@ class JointFitItem(CategoryItem): _category_entry_name = 'experiment_id' def __init__(self) -> None: + """Initialize the experiment id and weight descriptors.""" super().__init__() self._experiment_id: StringDescriptor = StringDescriptor( @@ -68,6 +69,7 @@ def experiment_id(self) -> StringDescriptor: @experiment_id.setter def experiment_id(self, value: str) -> None: + """Set the experiment identifier value.""" self._experiment_id.value = value @property @@ -83,6 +85,7 @@ def weight(self) -> NumericDescriptor: @weight.setter def weight(self, value: float) -> None: + """Set the joint-fit weight factor value.""" self._weight.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/base.py b/src/easydiffraction/analysis/categories/minimizer/base.py index 00f487726..0fc4175da 100644 --- a/src/easydiffraction/analysis/categories/minimizer/base.py +++ b/src/easydiffraction/analysis/categories/minimizer/base.py @@ -31,6 +31,7 @@ class MinimizerCategoryBase(CategoryItem, SwitchableCategoryBase): _fit_result_class: ClassVar[type[FitResultBase]] = FitResultBase def __init__(self) -> None: + """Initialize the minimizer type descriptor.""" super().__init__() self._type = StringDescriptor( name='type', diff --git a/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py index de5b73521..5af55a8d1 100644 --- a/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py +++ b/src/easydiffraction/analysis/categories/minimizer/bayesian_base.py @@ -167,6 +167,7 @@ def sampling_steps(self) -> IntegerDescriptor: @sampling_steps.setter def sampling_steps(self, value: int) -> None: + """Set the total sampler iterations per chain.""" self._sampling_steps.value = value @property @@ -176,6 +177,7 @@ def burn_in_steps(self) -> IntegerDescriptor: @burn_in_steps.setter def burn_in_steps(self, value: int) -> None: + """Set the sampler iterations discarded as warm-up.""" self._burn_in_steps.value = value @property @@ -185,6 +187,7 @@ def thinning_interval(self) -> IntegerDescriptor: @thinning_interval.setter def thinning_interval(self, value: int) -> None: + """Set the sampler thinning interval.""" self._thinning_interval.value = value @property @@ -194,6 +197,7 @@ def population_size(self) -> IntegerDescriptor: @population_size.setter def population_size(self, value: int) -> None: + """Set the number of chains or walkers.""" self._population_size.value = value @property @@ -203,6 +207,7 @@ def parallel_workers(self) -> IntegerDescriptor: @parallel_workers.setter def parallel_workers(self, value: int) -> None: + """Set the worker count; 0 uses all available CPUs.""" self._parallel_workers.value = value @property @@ -212,6 +217,7 @@ def initialization_method(self) -> StringDescriptor: @initialization_method.setter def initialization_method(self, value: InitializationMethodEnum | str) -> None: + """Set the sampler initialization method if supported.""" method = InitializationMethodEnum(value) if method not in self._supported_initialization_methods: supported = ', '.join(item.value for item in self._supported_initialization_methods) @@ -226,4 +232,5 @@ def random_seed(self) -> IntegerDescriptor: @random_seed.setter def random_seed(self, value: int | None) -> None: + """Set the random seed; None uses a system-derived seed.""" self._random_seed.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py index 8d7f1b638..51264dcfa 100644 --- a/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py +++ b/src/easydiffraction/analysis/categories/minimizer/bumps_dream.py @@ -34,6 +34,7 @@ class BumpsDreamMinimizer(BayesianMinimizerBase): ) def __init__(self) -> None: + """Initialize the BUMPS DREAM minimizer setting descriptors.""" 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) diff --git a/src/easydiffraction/analysis/categories/minimizer/emcee.py b/src/easydiffraction/analysis/categories/minimizer/emcee.py index 77143afca..54a2fe576 100644 --- a/src/easydiffraction/analysis/categories/minimizer/emcee.py +++ b/src/easydiffraction/analysis/categories/minimizer/emcee.py @@ -75,6 +75,7 @@ class EmceeMinimizer(BayesianMinimizerBase): ) def __init__(self) -> None: + """Initialize the emcee minimizer setting descriptors.""" 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) @@ -125,4 +126,5 @@ def proposal_moves(self) -> StringDescriptor: @proposal_moves.setter def proposal_moves(self, value: str) -> None: + """Set the single emcee proposal move.""" self._proposal_moves.value = value diff --git a/src/easydiffraction/analysis/categories/minimizer/lsq_base.py b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py index 15d40ebd6..1eff06e06 100644 --- a/src/easydiffraction/analysis/categories/minimizer/lsq_base.py +++ b/src/easydiffraction/analysis/categories/minimizer/lsq_base.py @@ -28,6 +28,7 @@ class LeastSquaresMinimizerBase(MinimizerCategoryBase): _result_descriptor_names: ClassVar[tuple[str, ...]] = () def __init__(self) -> None: + """Initialize the max-iterations setting descriptor.""" super().__init__() self._max_iterations = self._max_iterations_descriptor(self._default_max_iterations) @@ -55,4 +56,5 @@ def max_iterations(self) -> IntegerDescriptor: @max_iterations.setter def max_iterations(self, value: int) -> None: + """Set the maximum solver iterations.""" self._max_iterations.value = value diff --git a/src/easydiffraction/analysis/categories/sequential_fit/default.py b/src/easydiffraction/analysis/categories/sequential_fit/default.py index 96a56786e..e95db87d7 100644 --- a/src/easydiffraction/analysis/categories/sequential_fit/default.py +++ b/src/easydiffraction/analysis/categories/sequential_fit/default.py @@ -30,6 +30,7 @@ class SequentialFit(CategoryItem): ) def __init__(self) -> None: + """Initialize the sequential-fit setting descriptors.""" super().__init__() self._data_dir = StringDescriptor( @@ -91,6 +92,7 @@ def data_dir(self) -> StringDescriptor: @data_dir.setter def data_dir(self, value: str) -> None: + """Set the sequential-fit data directory.""" self._data_dir.value = value @property @@ -100,6 +102,7 @@ def file_pattern(self) -> StringDescriptor: @file_pattern.setter def file_pattern(self, value: str) -> None: + """Set the sequential-fit file glob pattern.""" self._file_pattern.value = value @property @@ -109,6 +112,7 @@ def max_workers(self) -> StringDescriptor: @max_workers.setter def max_workers(self, value: str) -> None: + """Set the sequential-fit worker-count token.""" self._max_workers.value = value @property @@ -118,6 +122,7 @@ def chunk_size(self) -> StringDescriptor: @chunk_size.setter def chunk_size(self, value: str) -> None: + """Set the sequential-fit chunk-size token.""" self._chunk_size.value = value @property @@ -127,6 +132,7 @@ def reverse(self) -> BoolDescriptor: @reverse.setter def reverse(self, value: bool) -> None: + """Set whether to process sequential-fit files in reverse.""" self._reverse.value = value @property diff --git a/src/easydiffraction/analysis/categories/sequential_fit_extract/default.py b/src/easydiffraction/analysis/categories/sequential_fit_extract/default.py index ef9e40a05..6a9c24cc2 100644 --- a/src/easydiffraction/analysis/categories/sequential_fit_extract/default.py +++ b/src/easydiffraction/analysis/categories/sequential_fit_extract/default.py @@ -62,6 +62,7 @@ class SequentialFitExtractItem(CategoryItem): _category_entry_name = 'id' def __init__(self) -> None: + """Initialize the extract-rule descriptors.""" super().__init__() self._id = StringDescriptor( @@ -111,6 +112,7 @@ def id(self) -> StringDescriptor: @id.setter def id(self, value: str) -> None: + """Set the extract-rule identifier.""" self._id.value = value @property @@ -120,6 +122,7 @@ def target(self) -> StringDescriptor: @target.setter def target(self, value: str) -> None: + """Set the diffrn attribute updated by this rule.""" self._target.value = value @property @@ -129,6 +132,7 @@ def pattern(self) -> StringDescriptor: @pattern.setter def pattern(self, value: str) -> None: + """Set the extract-rule capture-group regex.""" self._pattern.value = value @property @@ -138,6 +142,7 @@ def required(self) -> BoolDescriptor: @required.setter def required(self, value: bool) -> None: + """Set whether this extract rule must match every file.""" self._required.value = value diff --git a/src/easydiffraction/analysis/categories/software/base.py b/src/easydiffraction/analysis/categories/software/base.py index dcab532a6..8713c64d5 100644 --- a/src/easydiffraction/analysis/categories/software/base.py +++ b/src/easydiffraction/analysis/categories/software/base.py @@ -51,6 +51,7 @@ def name(self) -> StringDescriptor: @name.setter def name(self, value: str | None) -> None: + """Set the software name.""" self._name.value = value @property @@ -60,6 +61,7 @@ def version(self) -> StringDescriptor: @version.setter def version(self, value: str | None) -> None: + """Set the software version.""" self._version.value = value @property @@ -69,6 +71,7 @@ def url(self) -> StringDescriptor: @url.setter def url(self, value: str | None) -> None: + """Set the software project URL.""" self._url.value = value @property diff --git a/src/easydiffraction/analysis/categories/software/default.py b/src/easydiffraction/analysis/categories/software/default.py index 62430b8fc..f99059c88 100644 --- a/src/easydiffraction/analysis/categories/software/default.py +++ b/src/easydiffraction/analysis/categories/software/default.py @@ -25,6 +25,7 @@ class Software(CategoryItem): ) def __init__(self) -> None: + """Initialize the software-role and timestamp descriptors.""" super().__init__() self._framework = SoftwareRole( role_name='framework', @@ -67,6 +68,7 @@ def timestamp(self) -> StringDescriptor: @timestamp.setter def timestamp(self, value: str | None) -> None: + """Set the UTC timestamp of the provenance snapshot.""" self._timestamp.value = value @property diff --git a/src/easydiffraction/analysis/fit_helpers/bayesian.py b/src/easydiffraction/analysis/fit_helpers/bayesian.py index 665bf6cce..773d3dacc 100644 --- a/src/easydiffraction/analysis/fit_helpers/bayesian.py +++ b/src/easydiffraction/analysis/fit_helpers/bayesian.py @@ -561,6 +561,7 @@ def standard_deviations_from_summaries( def _maybe_scalar(value: object) -> float | None: + """Return ``value`` as a finite float, or ``None`` otherwise.""" if value is None: return None scalar = float(value) @@ -632,6 +633,7 @@ def _bayesian_overall_status( def _render_committed_parameter_table(parameters: list[object]) -> None: + """Render the table of committed (best-sample) parameter values.""" headers = [ 'datablock', 'category', @@ -667,6 +669,7 @@ def _render_posterior_summary_table( parameters: list[object], posterior_parameter_summaries: list[PosteriorParameterSummary], ) -> None: + """Render the posterior distribution summary table.""" if not posterior_parameter_summaries: console.print('No posterior parameter summaries available.') return @@ -709,6 +712,7 @@ def _build_posterior_summary_row( summary: PosteriorParameterSummary, parameters_by_name: dict[str, object], ) -> list[str]: + """Build one row of the posterior distribution summary table.""" parameter = parameters_by_name.get(summary.unique_name) identity = getattr(parameter, '_identity', None) datablock = getattr(identity, 'datablock_entry_name', 'N/A') @@ -731,10 +735,12 @@ def _build_posterior_summary_row( def _format_interval(interval: tuple[float, float]) -> str: + """Format a credible interval as a bracketed pair of values.""" return f'[{interval[0]:.4f}, {interval[1]:.4f}]' def _format_r_hat(value: float | None) -> str: + """Format an r-hat value, flagging poor convergence in red.""" if value is None or not np.isfinite(value): return 'N/A' formatted = f'{value:.3f}' @@ -744,6 +750,7 @@ def _format_r_hat(value: float | None) -> str: def _format_ess_bulk(value: float | None) -> str: + """Format a bulk ESS value, flagging low values in red.""" if value is None or not np.isfinite(value): return 'N/A' formatted = f'{value:.1f}' diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index a736fd0f4..bcbc2f627 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -72,6 +72,7 @@ class FitProgressTracker: """ def __init__(self) -> None: + """Initialize the tracker with empty progress state.""" self._iteration: int = 0 self._previous_chi2: float | None = None self._last_chi2: float | None = None @@ -410,6 +411,9 @@ def _initial_sampler_progress_row( clamped_iteration: int, clamped_progress: float, ) -> list[str]: + """ + Build the first sampler progress row of a run. + """ if self._df_rows: return [] @@ -441,6 +445,9 @@ def _continued_sampler_progress_row( clamped_iteration: int, clamped_progress: float, ) -> list[str]: + """ + Build a subsequent sampler progress row, if one is due. + """ if self._best_chi2 is not None and update.reduced_chi2 < self._best_chi2: self._best_chi2 = update.reduced_chi2 self._best_iteration = update.iteration @@ -474,6 +481,9 @@ def _should_render_sampler_row( force_report: bool, clamped_iteration: int, ) -> bool: + """ + Return whether a sampler progress row should be rendered now. + """ if iteration == self._last_reported_iteration: return False @@ -494,6 +504,9 @@ def _sampler_progress_row( phase: str, elapsed_time: float, ) -> list[str]: + """ + Return a sampler row with iteration, progress and posterior. + """ return [ self._sampler_iteration_label(clamped_iteration), f'{clamped_progress:.1f}%', @@ -522,6 +535,9 @@ def _sampler_status_row( ] def _finalize_sampler_tracking_row(self) -> None: + """ + Append or replace the closing sampler tracking row. + """ row = self._final_sampler_tracking_row() if row is None: return @@ -538,6 +554,9 @@ def _finalize_sampler_tracking_row(self) -> None: self.add_tracking_info(row) def _final_sampler_tracking_row(self) -> list[str] | None: + """ + Build the closing sampler row, or ``None`` if unavailable. + """ if self._last_iteration is None or self._sampler_total_iterations is None: return None @@ -563,6 +582,9 @@ def _final_sampler_tracking_row(self) -> list[str] | None: ] def _finalize_fit_tracking_row(self) -> None: + """ + Append or replace the closing least-squares fit row. + """ row = self._final_fit_tracking_row() if row is None: return @@ -579,6 +601,9 @@ def _finalize_fit_tracking_row(self) -> None: self.add_tracking_info(row) def _final_fit_tracking_row(self) -> list[str] | None: + """ + Build the closing fit row, or ``None`` if unavailable. + """ if self._last_iteration is None: return None @@ -590,6 +615,9 @@ def _final_fit_tracking_row(self) -> list[str] | None: ] def _resolved_final_sampler_progress(self) -> float: + """ + Return the final sampler progress percentage. + """ if self._last_sampler_progress_percent is not None: return self._last_sampler_progress_percent @@ -603,11 +631,17 @@ def _resolved_final_sampler_progress(self) -> float: ) def _resolved_final_sampler_elapsed_time(self) -> float | None: + """ + Return the final sampler elapsed time in seconds. + """ if self._fitting_time is not None: return self._fitting_time return self._last_sampler_elapsed_time def _sampler_iteration_label(self, iteration: int) -> str: + """ + Return an ``iteration/total`` label clamped to the total. + """ if self._sampler_total_iterations is None: msg = 'Sampler iteration labels require a configured total iteration count.' raise RuntimeError(msg) @@ -615,6 +649,9 @@ def _sampler_iteration_label(self, iteration: int) -> str: return f'{clamped_iteration}/{self._sampler_total_iterations}' def _print_completion_summary(self) -> None: + """ + Print the closing summary for the completed run. + """ if self._tracking_mode == TRACKING_MODE_SAMPLER: console.print('✅ Bayesian sampling complete.') return @@ -658,6 +695,9 @@ def _format_elapsed_time(self, elapsed_time: float | None = None) -> str: return f'{resolved_time:.2f}' def _should_render_fit_row(self, elapsed_time: float | None) -> bool: + """ + Return whether enough time elapsed to render a fit row. + """ if elapsed_time is None or self._last_progress_time is None: return False return elapsed_time - self._last_progress_time >= FIT_PROGRESS_UPDATE_SECONDS @@ -691,12 +731,18 @@ def _replace_last_tracking_row(self, row: list[str]) -> None: self._refresh_activity_indicator() def _default_activity_label(self) -> str: + """ + Return the default activity label for the tracking mode. + """ if self._tracking_mode == TRACKING_MODE_SAMPLER: return ACTIVITY_LABEL_PROCESSING return ACTIVITY_LABEL_FITTING @staticmethod def _activity_label_for_sampler_phase(phase: str) -> str: + """ + Map a sampler phase name to its activity-indicator label. + """ normalized_phase = phase.strip().lower() if normalized_phase == SAMPLER_PHASE_PRE_PROCESSING: return ACTIVITY_LABEL_PRE_PROCESSING @@ -711,9 +757,15 @@ def _activity_label_for_sampler_phase(phase: str) -> str: return ACTIVITY_LABEL_SAMPLING def _set_shared_display_handle(self, display_handle: object | None) -> None: + """ + Store a display handle shared with the activity indicator. + """ self._shared_display_handle = display_handle def _start_activity_indicator(self) -> None: + """ + Create and start the live activity indicator. + """ self._activity_indicator = ActivityIndicator( self._activity_label, verbosity=self._verbosity, @@ -723,6 +775,9 @@ def _start_activity_indicator(self) -> None: self._refresh_activity_indicator() def _stop_activity_indicator(self) -> None: + """ + Stop and discard the live activity indicator. + """ if self._activity_indicator is None: return @@ -730,6 +785,9 @@ def _stop_activity_indicator(self) -> None: self._activity_indicator = None def _set_activity_label(self, label: str) -> None: + """ + Update the activity-indicator label and refresh the view. + """ if label == self._activity_label: return @@ -737,6 +795,9 @@ def _set_activity_label(self, label: str) -> None: self._refresh_activity_indicator() def _refresh_activity_indicator(self) -> None: + """ + Refresh the activity indicator with the current table. + """ if self._activity_indicator is None: return @@ -750,6 +811,9 @@ def _refresh_activity_indicator(self) -> None: self._activity_indicator.update(label=self._activity_label) def _table_renderable(self) -> object: + """ + Build a renderable table from the accumulated rows. + """ return build_table_renderable( columns_headers=self._headers(), columns_alignment=self._alignments(), diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index 1ca3d3898..1f132e549 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -93,6 +93,7 @@ class Fitter: """Handles the fitting workflow using a pluggable minimizer.""" def __init__(self, selection: str = MinimizerTypeEnum.default()) -> None: + """Initialize the fitter with the selected minimizer.""" self.selection: str = selection self.engine: str = selection self.minimizer = MinimizerFactory.create(selection) diff --git a/src/easydiffraction/analysis/minimizers/base.py b/src/easydiffraction/analysis/minimizers/base.py index ac2d4e049..fd2af7bad 100644 --- a/src/easydiffraction/analysis/minimizers/base.py +++ b/src/easydiffraction/analysis/minimizers/base.py @@ -45,6 +45,7 @@ def __init__( method: str | None = None, max_iterations: int | None = None, ) -> None: + """Initialize the minimizer with optional configuration.""" self.name: str | None = name self.method: str | None = method self._max_iterations: int | None = max_iterations @@ -67,6 +68,7 @@ def max_iterations(self) -> int | None: @max_iterations.setter def max_iterations(self, value: int | None) -> None: + """Set the user-facing iteration limit.""" self._max_iterations = value def _start_tracking( diff --git a/src/easydiffraction/analysis/minimizers/bumps.py b/src/easydiffraction/analysis/minimizers/bumps.py index f80446cfc..b9256b433 100644 --- a/src/easydiffraction/analysis/minimizers/bumps.py +++ b/src/easydiffraction/analysis/minimizers/bumps.py @@ -31,6 +31,7 @@ def __init__( parameter_values: np.ndarray, residuals: np.ndarray | None, ) -> None: + """Record the evaluation count and last residual state.""" super().__init__('maximum number of residual evaluations reached') self.evaluation_count = evaluation_count self.parameter_values = parameter_values @@ -46,6 +47,7 @@ def __init__( objective_function: object, max_evaluations: int | None = None, ) -> None: + """Wrap the objective and BUMPS parameters for evaluation.""" self._bumps_params = bumps_params self._objective_function = objective_function self._max_evaluations = max_evaluations @@ -137,6 +139,7 @@ def __init__( n_points: int, n_parameters: int, ) -> None: + """Store the tracker and fit dimensions for reporting.""" self._tracker = tracker self._fitness = fitness self._n_points = n_points @@ -203,6 +206,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the BUMPS minimizer with default settings.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/bumps_amoeba.py b/src/easydiffraction/analysis/minimizers/bumps_amoeba.py index 3b916e6d4..1c002ba73 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_amoeba.py +++ b/src/easydiffraction/analysis/minimizers/bumps_amoeba.py @@ -28,6 +28,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the BUMPS Nelder-Mead simplex minimizer.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/bumps_de.py b/src/easydiffraction/analysis/minimizers/bumps_de.py index 98e5ef202..27266a1d6 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_de.py +++ b/src/easydiffraction/analysis/minimizers/bumps_de.py @@ -28,6 +28,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the BUMPS differential evolution minimizer.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/bumps_dream.py b/src/easydiffraction/analysis/minimizers/bumps_dream.py index 7bcdd5766..e87792b1d 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_dream.py +++ b/src/easydiffraction/analysis/minimizers/bumps_dream.py @@ -90,6 +90,7 @@ def __init__( total_generations: int, burn_steps: int, ) -> None: + """Precompute per-phase progress targets for reporting.""" self._tracker = tracker self._n_points = n_points self._n_parameters = n_parameters @@ -295,6 +296,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the DREAM minimizer with sampler defaults.""" super().__init__( name=name, method=method, @@ -315,6 +317,7 @@ def max_iterations(self) -> int: @max_iterations.setter def max_iterations(self, value: int) -> None: + """Reject ``max_iterations``; DREAM uses ``steps`` instead.""" del value sampler_name = self.type_info.description.partition('with ')[2].split()[0] msg = f"{sampler_name} sampler uses 'steps' instead of 'max_iterations'." @@ -327,6 +330,7 @@ def steps(self) -> int: @steps.setter def steps(self, value: int) -> None: + """Set the number of DREAM generations after burn-in.""" self._max_iterations = self._validated_positive_integer('steps', value) @property @@ -336,6 +340,7 @@ def burn(self) -> int | None: @burn.setter def burn(self, value: int | None) -> None: + """Set explicit DREAM burn-in generations, or ``None``.""" if value is None: self._burn = None return @@ -348,6 +353,7 @@ def thin(self) -> int: @thin.setter def thin(self, value: int) -> None: + """Set the DREAM thinning interval.""" self._thin = self._validated_positive_integer('thin', value) @property @@ -357,6 +363,7 @@ def pop(self) -> int: @pop.setter def pop(self, value: int) -> None: + """Set the DREAM population multiplier.""" self._pop = self._validated_positive_integer('pop', value) @property @@ -366,6 +373,7 @@ def parallel(self) -> int: @parallel.setter def parallel(self, value: int) -> None: + """Set the DREAM parallel worker count.""" self._parallel = self._validated_non_negative_integer('parallel', value) @property @@ -375,6 +383,7 @@ def init(self) -> DreamPopulationInitializationEnum: @init.setter def init(self, value: DreamPopulationInitializationEnum | str) -> None: + """Set the DREAM population initializer.""" self._init = self._validated_init(value) def _resolve_random_seed(self, random_seed: int | None) -> int: diff --git a/src/easydiffraction/analysis/minimizers/bumps_lm.py b/src/easydiffraction/analysis/minimizers/bumps_lm.py index c24cab240..8e6624637 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_lm.py +++ b/src/easydiffraction/analysis/minimizers/bumps_lm.py @@ -30,6 +30,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the BUMPS Levenberg-Marquardt minimizer.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/dfols.py b/src/easydiffraction/analysis/minimizers/dfols.py index b8d6851bb..e1f25f6cc 100644 --- a/src/easydiffraction/analysis/minimizers/dfols.py +++ b/src/easydiffraction/analysis/minimizers/dfols.py @@ -28,11 +28,25 @@ def __init__( max_iterations: int = DEFAULT_MAX_ITERATIONS, **kwargs: object, ) -> None: + """Initialize the DFO-LS minimizer with default settings.""" super().__init__(name=name, method=None, max_iterations=max_iterations) # Intentionally unused, accepted for API compatibility del kwargs def _prepare_solver_args(self, parameters: list[object]) -> dict[str, object]: # noqa: PLR6301 + """ + Build the initial guess and bounds for the DFO-LS solver. + + Parameters + ---------- + parameters : list[object] + Parameters being optimized. + + Returns + ------- + dict[str, object] + Mapping with the initial point and bound arrays. + """ x0 = [] bounds_lower = [] bounds_upper = [] @@ -44,6 +58,7 @@ def _prepare_solver_args(self, parameters: list[object]) -> dict[str, object]: return {'x0': np.array(x0), 'bounds': bounds} def _run_solver(self, objective_function: object, **kwargs: object) -> object: + """Run the DFO-LS solver on the objective function.""" x0 = kwargs.get('x0') bounds = kwargs.get('bounds') return solve(objective_function, x0=x0, bounds=bounds, maxfun=self.max_iterations) diff --git a/src/easydiffraction/analysis/minimizers/emcee.py b/src/easydiffraction/analysis/minimizers/emcee.py index d3be60b65..c2b91e0c3 100644 --- a/src/easydiffraction/analysis/minimizers/emcee.py +++ b/src/easydiffraction/analysis/minimizers/emcee.py @@ -68,6 +68,7 @@ def __init__( parameter_names: list[str], objective_function: Callable[[dict[str, object]], object], ) -> None: + """Capture parameters, names and objective for sampling.""" self._parameter_names = parameter_names self._objective_function = objective_function self._bounds = { @@ -123,6 +124,7 @@ def __init__( total_steps: int, burn_steps: int, ) -> None: + """Store the tracker and step counts for progress rows.""" self._tracker = tracker self._total_steps = max(1, total_steps) self._burn_steps = min(max(0, burn_steps), self._total_steps) @@ -289,6 +291,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_NSTEPS, ) -> None: + """Initialize the emcee minimizer with sampler defaults.""" super().__init__( name=name, method=method, @@ -310,6 +313,7 @@ def nsteps(self) -> int: @nsteps.setter def nsteps(self, value: int) -> None: + """Set the number of emcee steps per walker.""" self._max_iterations = self._validated_positive_integer('nsteps', value) @property @@ -319,6 +323,7 @@ def nburn(self) -> int: @nburn.setter def nburn(self, value: int) -> None: + """Set the number of burn-in steps to discard.""" self._nburn = self._validated_non_negative_integer('nburn', value) @property @@ -328,6 +333,7 @@ def thin(self) -> int: @thin.setter def thin(self, value: int) -> None: + """Set the emcee thinning interval.""" self._thin = self._validated_positive_integer('thin', value) @property @@ -337,6 +343,7 @@ def nwalkers(self) -> int: @nwalkers.setter def nwalkers(self, value: int) -> None: + """Set the number of emcee walkers.""" self._nwalkers = self._validated_positive_integer('nwalkers', value) @property @@ -348,6 +355,7 @@ def parallel_workers(self) -> int: @parallel_workers.setter def parallel_workers(self, value: int) -> None: + """Set the number of parallel sampling workers.""" self._parallel_workers = self._validated_non_negative_integer('parallel_workers', value) @property @@ -357,6 +365,7 @@ def initialization_method(self) -> InitializationMethodEnum: @initialization_method.setter def initialization_method(self, value: InitializationMethodEnum | str) -> None: + """Set the emcee walker initialization method.""" self._initialization_method = self._validated_initialization_method(value) @property @@ -366,6 +375,7 @@ def proposal_moves(self) -> str: @proposal_moves.setter def proposal_moves(self, value: str) -> None: + """Set the emcee proposal move name.""" self._proposal_moves = self._validated_proposal_moves(value) def fit( diff --git a/src/easydiffraction/analysis/minimizers/lmfit.py b/src/easydiffraction/analysis/minimizers/lmfit.py index dce336617..b564d3512 100644 --- a/src/easydiffraction/analysis/minimizers/lmfit.py +++ b/src/easydiffraction/analysis/minimizers/lmfit.py @@ -28,6 +28,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the lmfit minimizer with default settings.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/lmfit_least_squares.py b/src/easydiffraction/analysis/minimizers/lmfit_least_squares.py index 4daa20dff..06089c66c 100644 --- a/src/easydiffraction/analysis/minimizers/lmfit_least_squares.py +++ b/src/easydiffraction/analysis/minimizers/lmfit_least_squares.py @@ -30,6 +30,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the lmfit least_squares minimizer.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/analysis/minimizers/lmfit_leastsq.py b/src/easydiffraction/analysis/minimizers/lmfit_leastsq.py index 8d2050487..27626a5bb 100644 --- a/src/easydiffraction/analysis/minimizers/lmfit_leastsq.py +++ b/src/easydiffraction/analysis/minimizers/lmfit_leastsq.py @@ -32,6 +32,7 @@ def __init__( method: str = DEFAULT_METHOD, max_iterations: int = DEFAULT_MAX_ITERATIONS, ) -> None: + """Initialize the lmfit leastsq minimizer.""" super().__init__( name=name, method=method, diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py index 51d11f10e..757e379f5 100644 --- a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py +++ b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py @@ -22,6 +22,7 @@ class CwlInstrumentBase(InstrumentBase): """Base class for constant-wavelength instruments.""" def __init__(self) -> None: + """Initialize the constant-wavelength instrument base.""" super().__init__() self._setup_wavelength: Parameter = Parameter( @@ -57,6 +58,7 @@ def setup_wavelength(self) -> Parameter: @setup_wavelength.setter def setup_wavelength(self, value: float) -> None: + """Set the incident neutron or X-ray wavelength (Å).""" self._setup_wavelength.value = value @@ -78,6 +80,7 @@ class CwlScInstrument(CwlInstrumentBase): ) def __init__(self) -> None: + """Initialize the CW single-crystal diffractometer.""" super().__init__() @@ -103,6 +106,7 @@ class CwlPdInstrument(CwlInstrumentBase): ) def __init__(self) -> None: + """Initialize the CW powder diffractometer.""" super().__init__() self._calib_twotheta_offset: Parameter = Parameter( @@ -138,4 +142,5 @@ def calib_twotheta_offset(self) -> Parameter: @calib_twotheta_offset.setter def calib_twotheta_offset(self, value: float) -> None: + """Set the instrument misalignment offset (deg).""" self._calib_twotheta_offset.value = value diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py b/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py index 06865af21..d78f758e6 100644 --- a/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py +++ b/src/easydiffraction/datablocks/experiment/categories/instrument/tof.py @@ -36,6 +36,7 @@ class TofScInstrument(InstrumentBase): ) def __init__(self) -> None: + """Initialize the TOF single-crystal diffractometer.""" super().__init__() @@ -57,6 +58,7 @@ class TofPdInstrument(InstrumentBase): ) def __init__(self) -> None: + """Initialize the TOF powder diffractometer.""" super().__init__() self._setup_twotheta_bank: Parameter = Parameter( @@ -152,6 +154,7 @@ def setup_twotheta_bank(self) -> Parameter: @setup_twotheta_bank.setter def setup_twotheta_bank(self, value: float) -> None: + """Set the detector bank position (deg).""" self._setup_twotheta_bank.value = value @property @@ -166,6 +169,7 @@ def calib_d_to_tof_offset(self) -> Parameter: @calib_d_to_tof_offset.setter def calib_d_to_tof_offset(self, value: float) -> None: + """Set the TOF offset (μs).""" self._calib_d_to_tof_offset.value = value @property @@ -180,6 +184,7 @@ def calib_d_to_tof_linear(self) -> Parameter: @calib_d_to_tof_linear.setter def calib_d_to_tof_linear(self, value: float) -> None: + """Set the TOF linear conversion (μs/Å).""" self._calib_d_to_tof_linear.value = value @property @@ -194,6 +199,7 @@ def calib_d_to_tof_quad(self) -> Parameter: @calib_d_to_tof_quad.setter def calib_d_to_tof_quad(self, value: float) -> None: + """Set the TOF quadratic correction (μs/Ų).""" self._calib_d_to_tof_quad.value = value @property @@ -208,4 +214,5 @@ def calib_d_to_tof_recip(self) -> Parameter: @calib_d_to_tof_recip.setter def calib_d_to_tof_recip(self, value: float) -> None: + """Set the TOF reciprocal velocity correction (μs·Å).""" self._calib_d_to_tof_recip.value = value diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/base.py b/src/easydiffraction/datablocks/experiment/categories/peak/base.py index 9359ad0ac..abbfab723 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/base.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/base.py @@ -25,6 +25,7 @@ class PeakBase(CategoryItem, SwitchableCategoryBase): _swap_method_name = '_swap_peak' def __init__(self) -> None: + """Initialize the peak profile base with its type descriptor.""" super().__init__() type_info = getattr(type(self), 'type_info', None) diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py b/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py index 3728be36f..bb48fdbb1 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/cwl.py @@ -38,6 +38,7 @@ class CwlPseudoVoigt( ) def __init__(self) -> None: + """Initialize the constant-wavelength pseudo-Voigt peak.""" super().__init__() @@ -62,6 +63,7 @@ class CwlPseudoVoigtEmpiricalAsymmetry( ) def __init__(self) -> None: + """Initialize the pseudo-Voigt with empirical asymmetry.""" super().__init__() @@ -86,4 +88,5 @@ class CwlThompsonCoxHastings( ) def __init__(self) -> None: + """Initialize the Thompson-Cox-Hastings FCJ peak.""" super().__init__() diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py index 020a4e9ea..ca6fc37da 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/cwl_mixins.py @@ -19,6 +19,7 @@ class CwlBroadeningMixin: """CWL Gaussian and Lorentz broadening parameters.""" def __init__(self) -> None: + """Initialize the CWL broadening parameters.""" super().__init__() self._broad_gauss_u: Parameter = Parameter( @@ -133,6 +134,7 @@ def broad_gauss_u(self) -> Parameter: @broad_gauss_u.setter def broad_gauss_u(self, value: float) -> None: + """Set Gaussian broadening from size/resolution (deg²).""" self._broad_gauss_u.value = value @property @@ -147,6 +149,7 @@ def broad_gauss_v(self) -> Parameter: @broad_gauss_v.setter def broad_gauss_v(self, value: float) -> None: + """Set Gaussian broadening instrumental contribution (deg²).""" self._broad_gauss_v.value = value @property @@ -161,6 +164,7 @@ def broad_gauss_w(self) -> Parameter: @broad_gauss_w.setter def broad_gauss_w(self, value: float) -> None: + """Set Gaussian broadening instrumental contribution (deg²).""" self._broad_gauss_w.value = value @property @@ -175,6 +179,7 @@ def broad_lorentz_x(self) -> Parameter: @broad_lorentz_x.setter def broad_lorentz_x(self, value: float) -> None: + """Set Lorentzian broadening from strain effects (deg).""" self._broad_lorentz_x.value = value @property @@ -189,6 +194,7 @@ def broad_lorentz_y(self) -> Parameter: @broad_lorentz_y.setter def broad_lorentz_y(self, value: float) -> None: + """Set Lorentzian broadening from defects (deg).""" self._broad_lorentz_y.value = value @@ -196,6 +202,7 @@ class EmpiricalAsymmetryMixin: """Empirical CWL peak asymmetry parameters.""" def __init__(self) -> None: + """Initialize the empirical CWL peak asymmetry parameters.""" super().__init__() self._asym_empir_1: Parameter = Parameter( @@ -267,6 +274,7 @@ def asym_empir_1(self) -> Parameter: @asym_empir_1.setter def asym_empir_1(self, value: float) -> None: + """Set the empirical asymmetry coefficient p1.""" self._asym_empir_1.value = value @property @@ -281,6 +289,7 @@ def asym_empir_2(self) -> Parameter: @asym_empir_2.setter def asym_empir_2(self, value: float) -> None: + """Set the empirical asymmetry coefficient p2.""" self._asym_empir_2.value = value @property @@ -295,6 +304,7 @@ def asym_empir_3(self) -> Parameter: @asym_empir_3.setter def asym_empir_3(self, value: float) -> None: + """Set the empirical asymmetry coefficient p3.""" self._asym_empir_3.value = value @property @@ -309,6 +319,7 @@ def asym_empir_4(self) -> Parameter: @asym_empir_4.setter def asym_empir_4(self, value: float) -> None: + """Set the empirical asymmetry coefficient p4.""" self._asym_empir_4.value = value @@ -316,6 +327,7 @@ class FcjAsymmetryMixin: """Finger-Cox-Jephcoat (FCJ) asymmetry parameters.""" def __init__(self) -> None: + """Initialize the Finger-Cox-Jephcoat asymmetry parameters.""" super().__init__() self._asym_fcj_1: Parameter = Parameter( @@ -361,6 +373,7 @@ def asym_fcj_1(self) -> Parameter: @asym_fcj_1.setter def asym_fcj_1(self, value: float) -> None: + """Set the Finger-Cox-Jephcoat asymmetry parameter 1.""" self._asym_fcj_1.value = value @property @@ -375,4 +388,5 @@ def asym_fcj_2(self) -> Parameter: @asym_fcj_2.setter def asym_fcj_2(self, value: float) -> None: + """Set the Finger-Cox-Jephcoat asymmetry parameter 2.""" self._asym_fcj_2.value = value diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/tof.py b/src/easydiffraction/datablocks/experiment/categories/peak/tof.py index 086a71d3d..59656a61f 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/tof.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/tof.py @@ -54,6 +54,7 @@ class TofPseudoVoigt( ) def __init__(self) -> None: + """Initialize the non-convoluted pseudo-Voigt TOF peak.""" super().__init__() @@ -78,6 +79,7 @@ class TofJorgensen( ) def __init__(self) -> None: + """Initialize the Jorgensen TOF peak profile.""" super().__init__() @@ -103,6 +105,7 @@ class TofJorgensenVonDreele( ) def __init__(self) -> None: + """Initialize the Jorgensen-Von Dreele TOF peak profile.""" super().__init__() @@ -128,4 +131,5 @@ class TofDoubleJorgensenVonDreele( ) def __init__(self) -> None: + """Initialize the double Jorgensen-Von Dreele TOF peak.""" super().__init__() diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py index 0ebd66b16..893618c67 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/tof_mixins.py @@ -31,6 +31,7 @@ class TofGaussianBroadeningMixin: """ def __init__(self) -> None: + """Initialize the TOF Gaussian broadening parameters.""" super().__init__() self._broad_gauss_sigma_0 = Parameter( @@ -97,6 +98,7 @@ def broad_gauss_sigma_0(self) -> Parameter: @broad_gauss_sigma_0.setter def broad_gauss_sigma_0(self, value: float) -> None: + """Set Gaussian broadening (instrumental resolution) (μs²).""" self._broad_gauss_sigma_0.value = value @property @@ -111,6 +113,7 @@ def broad_gauss_sigma_1(self) -> Parameter: @broad_gauss_sigma_1.setter def broad_gauss_sigma_1(self, value: float) -> None: + """Set Gaussian broadening (dependent on d-spacing) (μs/Å).""" self._broad_gauss_sigma_1.value = value @property @@ -125,6 +128,7 @@ def broad_gauss_sigma_2(self) -> Parameter: @broad_gauss_sigma_2.setter def broad_gauss_sigma_2(self, value: float) -> None: + """Set Gaussian broadening (instrument term) (μs²/Ų).""" self._broad_gauss_sigma_2.value = value @@ -132,6 +136,7 @@ class TofLorentzianBroadeningMixin: """TOF Lorentzian broadening parameters γ₀, γ₁, γ₂.""" def __init__(self) -> None: + """Initialize the TOF Lorentzian broadening parameters.""" super().__init__() self._broad_lorentz_gamma_0 = Parameter( @@ -198,6 +203,7 @@ def broad_lorentz_gamma_0(self) -> Parameter: @broad_lorentz_gamma_0.setter def broad_lorentz_gamma_0(self, value: float) -> None: + """Set Lorentzian broadening (microstrain effects) (μs).""" self._broad_lorentz_gamma_0.value = value @property @@ -212,6 +218,9 @@ def broad_lorentz_gamma_1(self) -> Parameter: @broad_lorentz_gamma_1.setter def broad_lorentz_gamma_1(self, value: float) -> None: + """ + Set Lorentzian broadening (dependent on d-spacing) (μs/Å). + """ self._broad_lorentz_gamma_1.value = value @property @@ -226,6 +235,9 @@ def broad_lorentz_gamma_2(self) -> Parameter: @broad_lorentz_gamma_2.setter def broad_lorentz_gamma_2(self, value: float) -> None: + """ + Set Lorentzian broadening (instrument-dependent) (μs²/Ų). + """ self._broad_lorentz_gamma_2.value = value @@ -241,6 +253,7 @@ class TofBackToBackExponentialMixin: """ def __init__(self) -> None: + """Initialize the back-to-back exponential parameters.""" super().__init__() self._exp_rise_alpha_0 = Parameter( @@ -324,6 +337,7 @@ def exp_rise_alpha_0(self) -> Parameter: @exp_rise_alpha_0.setter def exp_rise_alpha_0(self, value: float) -> None: + """Set the back-to-back exponential rise α₀ (μs).""" self._exp_rise_alpha_0.value = value @property @@ -338,6 +352,7 @@ def exp_rise_alpha_1(self) -> Parameter: @exp_rise_alpha_1.setter def exp_rise_alpha_1(self, value: float) -> None: + """Set the back-to-back exponential rise α₁ (μs/Å).""" self._exp_rise_alpha_1.value = value @property @@ -352,6 +367,7 @@ def exp_decay_beta_0(self) -> Parameter: @exp_decay_beta_0.setter def exp_decay_beta_0(self, value: float) -> None: + """Set the back-to-back exponential decay β₀ (μs).""" self._exp_decay_beta_0.value = value @property @@ -366,6 +382,7 @@ def exp_decay_beta_1(self) -> Parameter: @exp_decay_beta_1.setter def exp_decay_beta_1(self, value: float) -> None: + """Set the back-to-back exponential decay β₁ (μs/Å).""" self._exp_decay_beta_1.value = value @@ -382,6 +399,7 @@ class TofDoubleExponentialMixin: """ def __init__(self) -> None: + """Initialize the double back-to-back exponential parameters.""" super().__init__() self._dexp_rise_alpha_1 = Parameter( @@ -521,6 +539,7 @@ def dexp_rise_alpha_1(self) -> Parameter: @dexp_rise_alpha_1.setter def dexp_rise_alpha_1(self, value: float) -> None: + """Set the double-exp rise parameter α₁ (μs).""" self._dexp_rise_alpha_1.value = value @property @@ -535,6 +554,7 @@ def dexp_rise_alpha_2(self) -> Parameter: @dexp_rise_alpha_2.setter def dexp_rise_alpha_2(self, value: float) -> None: + """Set the double-exp rise parameter α₂ (μs/Å).""" self._dexp_rise_alpha_2.value = value @property @@ -549,6 +569,7 @@ def dexp_decay_beta_00(self) -> Parameter: @dexp_decay_beta_00.setter def dexp_decay_beta_00(self, value: float) -> None: + """Set the double-exp first-regime decay β₀₀ (μs).""" self._dexp_decay_beta_00.value = value @property @@ -563,6 +584,7 @@ def dexp_decay_beta_01(self) -> Parameter: @dexp_decay_beta_01.setter def dexp_decay_beta_01(self, value: float) -> None: + """Set the double-exp first-regime decay β₀₁ (μs/Å).""" self._dexp_decay_beta_01.value = value @property @@ -577,6 +599,7 @@ def dexp_decay_beta_10(self) -> Parameter: @dexp_decay_beta_10.setter def dexp_decay_beta_10(self, value: float) -> None: + """Set the double-exp second-regime decay β₁₀ (μs).""" self._dexp_decay_beta_10.value = value @property @@ -591,6 +614,7 @@ def dexp_switch_r_01(self) -> Parameter: @dexp_switch_r_01.setter def dexp_switch_r_01(self, value: float) -> None: + """Set the double-exp switching function r₀₁.""" self._dexp_switch_r_01.value = value @property @@ -605,6 +629,7 @@ def dexp_switch_r_02(self) -> Parameter: @dexp_switch_r_02.setter def dexp_switch_r_02(self, value: float) -> None: + """Set the double-exp switching function r₀₂.""" self._dexp_switch_r_02.value = value @property @@ -619,4 +644,5 @@ def dexp_switch_r_03(self) -> Parameter: @dexp_switch_r_03.setter def dexp_switch_r_03(self, value: float) -> None: + """Set the double-exp switching function r₀₃.""" self._dexp_switch_r_03.value = value diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/total.py b/src/easydiffraction/datablocks/experiment/categories/peak/total.py index 2462d95c5..3bd16a633 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/total.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/total.py @@ -37,4 +37,5 @@ class TotalGaussianDampedSinc( ) def __init__(self) -> None: + """Initialize the Gaussian-damped sinc PDF peak profile.""" super().__init__() diff --git a/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py b/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py index df3e129d4..15b2c539a 100644 --- a/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py +++ b/src/easydiffraction/datablocks/experiment/categories/peak/total_mixins.py @@ -19,6 +19,7 @@ class TotalBroadeningMixin: """PDF broadening/damping/sharpening parameters.""" def __init__(self) -> None: + """Initialize the PDF broadening parameters.""" super().__init__() self._damp_q = Parameter( @@ -140,6 +141,9 @@ def damp_q(self) -> Parameter: @damp_q.setter def damp_q(self, value: float) -> None: + """ + Set Q-resolution damping for high-r PDF amplitude (Å⁻¹). + """ self._damp_q.value = value @property @@ -154,6 +158,9 @@ def broad_q(self) -> Parameter: @broad_q.setter def broad_q(self, value: float) -> None: + """ + Set quadratic broadening from thermal uncertainty (Å⁻²). + """ self._broad_q.value = value @property @@ -168,6 +175,9 @@ def cutoff_q(self) -> Parameter: @cutoff_q.setter def cutoff_q(self, value: float) -> None: + """ + Set the Q-value cutoff for the Fourier transform (Å⁻¹). + """ self._cutoff_q.value = value @property @@ -182,6 +192,7 @@ def sharp_delta_1(self) -> Parameter: @sharp_delta_1.setter def sharp_delta_1(self, value: float) -> None: + """Set the peak sharpening coefficient (1/r dependence) (Å).""" self._sharp_delta_1.value = value @property @@ -196,6 +207,7 @@ def sharp_delta_2(self) -> Parameter: @sharp_delta_2.setter def sharp_delta_2(self, value: float) -> None: + """Set the sharpening coefficient (1/r² dependence) (Ų).""" self._sharp_delta_2.value = value @property @@ -210,4 +222,5 @@ def damp_particle_diameter(self) -> Parameter: @damp_particle_diameter.setter def damp_particle_diameter(self, value: float) -> None: + """Set particle diameter for spherical envelope damping (Å).""" self._damp_particle_diameter.value = value diff --git a/src/easydiffraction/display/base.py b/src/easydiffraction/display/base.py index 4921b68a8..3df6dc7e4 100644 --- a/src/easydiffraction/display/base.py +++ b/src/easydiffraction/display/base.py @@ -24,6 +24,7 @@ class RendererBase(SingletonBase, ABC): """ def __init__(self) -> None: + """Initialise with the default engine and its backend.""" self._engine = self._default_engine() self._backend = self._factory().create(self._engine) diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index a20a8dd82..d4d35fa2f 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -283,6 +283,7 @@ class PlotlyPlotter(PlotterBase): _supports_graphical_heatmap: bool = True def __init__(self) -> None: + """Set the default Plotly template and renderer.""" if hasattr(pio, 'templates'): pio.templates.default = self._default_template_name() if in_pycharm(): @@ -433,21 +434,25 @@ def _apply_hover_label_style( @staticmethod def _background_color_for_template(template: str) -> str | None: + """Return the background colour for a Plotly template.""" theme_colors = display_theme_colors_for_template(template) return theme_colors.background if theme_colors is not None else None @staticmethod def _axis_frame_color_for_template(template: str) -> str | None: + """Return the axis-frame colour for a Plotly template.""" theme_colors = display_theme_colors_for_template(template) return theme_colors.axis_frame if theme_colors is not None else None @staticmethod def _inner_tick_grid_color_for_template(template: str) -> str | None: + """Return the inner tick/grid colour for a template.""" theme_colors = display_theme_colors_for_template(template) return theme_colors.inner_tick_grid if theme_colors is not None else None @staticmethod def _legend_background_color_for_template(template: str) -> str | None: + """Return the legend background colour for a template.""" theme_colors = display_theme_colors_for_template(template) return theme_colors.legend_background if theme_colors is not None else None @@ -1712,6 +1717,7 @@ def _has_visible_legend(fig: object) -> bool: """Return whether a figure exposes at least one legend entry.""" def _trace_value(trace: object, field_name: str) -> object: + """Return a trace field from attribute or kwargs.""" value = getattr(trace, field_name, None) if value is not None: return value @@ -2418,6 +2424,7 @@ def _main_intensity_series( y_meas: np.ndarray, y_calc: np.ndarray, ) -> list[np.ndarray]: + """Collect all intensity series shown in the main row.""" main_series = [y_meas, y_calc] for values in ( plot_spec.y_bkg, @@ -2436,6 +2443,7 @@ def _append_non_empty_series( main_series: list[np.ndarray], values: np.ndarray | None, ) -> None: + """Append values to the series list when non-empty.""" if values is None: return @@ -2445,6 +2453,7 @@ def _append_non_empty_series( @staticmethod def _predictive_draw_array(values: object | None) -> np.ndarray | None: + """Return predictive draws as a 2D array, or None if absent.""" if values is None: return None @@ -2553,6 +2562,7 @@ def build_powder_meas_vs_calc_figure( @staticmethod def _create_powder_composite_figure(layout: PowderCompositeRows) -> object: + """Create the shared-x subplot figure for the composite plot.""" return make_subplots( rows=layout.row_count, cols=1, @@ -2567,6 +2577,7 @@ def _add_predictive_band_traces( fig: object, plot_spec: PowderMeasVsCalcSpec, ) -> None: + """Add the 95% predictive band traces to the main row.""" if plot_spec.predictive_lower_95 is None or plot_spec.predictive_upper_95 is None: return @@ -2586,6 +2597,7 @@ def _add_main_intensity_traces( hover_data: object, hover_template: str, ) -> None: + """Add measured, background, and calculated traces.""" meas_trace = self._get_powder_trace( plot_spec.x, plot_spec.y_meas, @@ -2633,6 +2645,7 @@ def _add_predictive_draw_traces( fig: object, plot_spec: PowderMeasVsCalcSpec, ) -> None: + """Add capped posterior predictive draw traces.""" predictive_draws = self._predictive_draw_array(plot_spec.predictive_draws) if predictive_draws is None: return @@ -2662,6 +2675,7 @@ def _add_bragg_tick_traces( plot_spec: PowderMeasVsCalcSpec, layout: PowderCompositeRows, ) -> None: + """Add one Bragg tick trace per phase to the Bragg row.""" if layout.bragg_row is None: return @@ -2686,6 +2700,7 @@ def _add_residual_trace( hover_data: object, hover_template: str, ) -> float | None: + """Add the residual trace and return its symmetric limit.""" if layout.residual_row is None or plot_spec.y_resid is None: return None @@ -2710,6 +2725,7 @@ def _configure_powder_composite_layout( plot_spec: PowderMeasVsCalcSpec, layout: PowderCompositeRows, ) -> None: + """Configure the composite figure height, title, and legend.""" fig.update_layout( height=self._composite_figure_height(layout), margin={ @@ -2741,6 +2757,7 @@ def _configure_powder_composite_axes( main_y_range: tuple[float, float], residual_limit: float | None, ) -> None: + """Configure the main, Bragg, and residual axes.""" self._configure_shared_composite_axes( fig=fig, row_count=layout.row_count, @@ -2783,6 +2800,7 @@ def _configure_shared_composite_axes( x_min: float | None, x_max: float | None, ) -> None: + """Apply shared x/y axis styling to every composite row.""" axis_frame_color = self._axis_frame_color() for row_idx in range(1, row_count + 1): x_axis_kwargs = { @@ -2817,6 +2835,7 @@ def _configure_bragg_axes( plot_spec: PowderMeasVsCalcSpec, layout: PowderCompositeRows, ) -> None: + """Configure the Bragg row's phase-labelled y axis.""" fig.update_yaxes( tickmode='array', tickvals=[float(idx + 1) for idx in range(len(plot_spec.bragg_tick_sets))], @@ -2840,6 +2859,7 @@ def _configure_residual_axes( layout: PowderCompositeRows, residual_limit: float, ) -> None: + """Configure the residual row's symmetric y axis and x title.""" residual_tick_limit = self._get_display_tick_limit(residual_limit) fig.update_yaxes( range=[-residual_limit, residual_limit], diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 2688711e9..8dd2b15d0 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -40,6 +40,7 @@ ) from easydiffraction.display.plotters.plotly import TITLE_FONT_SIZE as PLOTLY_TITLE_FONT_SIZE from easydiffraction.display.plotters.plotly import PlotlyPlotter +from easydiffraction.display.plotters.plotly import single_crystal_axis_range from easydiffraction.display.tables import TableRenderer from easydiffraction.utils.environment import in_jupyter from easydiffraction.utils.logging import console @@ -275,6 +276,7 @@ class Plotter(RendererBase): # ------------------------------------------------------------------ def __init__(self) -> None: + """Initialise default axis limits, height, and project ref.""" super().__init__() # X-axis limits self._x_min = DEFAULT_MIN @@ -303,10 +305,12 @@ def _update_project_categories(self, expt_name: str) -> None: @classmethod def _factory(cls) -> type[RendererFactoryBase]: # type: ignore[override] + """Return the plotter engine factory.""" return PlotterFactory @classmethod def _default_engine(cls) -> str: + """Return the default plotter engine name.""" return PlotterEngineEnum.default().value # ------------------------------------------------------------------ @@ -4085,12 +4089,17 @@ def _plot_single_crystal_posterior_predictive_summary( 'su(I²meas): %{customdata[2]:,.2f}' ) + axis_min, axis_max = single_crystal_axis_range( + best_sample_prediction, + y_meas, + y_meas_su, + ) fig = go.Figure( data=[trace], layout=PlotlyPlotter._get_layout( f"Posterior predictive reflection check for experiment 🔬 '{expt_name}'", axes_labels, - shapes=[PlotlyPlotter._get_diagonal_shape()], + shapes=[PlotlyPlotter._get_diagonal_shape(axis_min, axis_max)], ), ) self._show_plot_figure(fig) @@ -5710,6 +5719,9 @@ def _bragg_tick_x_values( expt_name: str, x_axis: object, ) -> object | None: + """ + Return Bragg tick x values for the requested x axis. + """ x_name = getattr(x_axis, 'value', x_axis) if x_name == XAxisType.D_SPACING: return Plotter._bragg_tick_d_spacing(refln=refln, experiment=experiment) @@ -5730,6 +5742,9 @@ def _bragg_tick_attr( name: str, expt_name: str, ) -> object | None: + """ + Return a named reflection attribute, warning if absent. + """ value = getattr(refln, name, None) if value is not None: return value @@ -5746,6 +5761,9 @@ def _bragg_tick_arrays( refln: object, expt_name: str, ) -> dict[str, np.ndarray] | None: + """ + Collect required reflection arrays, warning on any missing. + """ arrays: dict[str, np.ndarray] = {} for name in ( 'phase_id', @@ -5772,6 +5790,9 @@ def _bragg_tick_mask( x_min: float | None, x_max: float | None, ) -> np.ndarray: + """ + Return a boolean mask of ticks within the x range. + """ lower_bound = DEFAULT_MIN if x_min is None else min(x_min, x_max) upper_bound = DEFAULT_MAX if x_max is None else max(x_min, x_max) return (x_values >= lower_bound) & (x_values <= upper_bound) @@ -5782,6 +5803,9 @@ def _group_bragg_tick_sets( arrays: dict[str, np.ndarray], mask: np.ndarray, ) -> tuple[BraggTickSet, ...]: + """ + Group masked reflection arrays into per-phase tick sets. + """ phase_ids = arrays['phase_id'][mask] unique_phase_ids = [] for raw_phase_id in phase_ids: @@ -6024,6 +6048,7 @@ class PlotterFactory(RendererFactoryBase): @classmethod def _registry(cls) -> dict: + """Return the ASCII and Plotly plotter engine registry.""" return { PlotterEngineEnum.ASCII.value: { 'description': PlotterEngineEnum.ASCII.description(), diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py index 1a9a73bc4..202286aa6 100644 --- a/src/easydiffraction/display/progress.py +++ b/src/easydiffraction/display/progress.py @@ -92,6 +92,7 @@ class _TerminalLiveHandle: """ def __init__(self, *, console: object, auto_refresh: bool = True) -> None: + """Start a Rich live display on the given console.""" self._renderable: object = Text('') self._live = Live( console=console, @@ -103,6 +104,7 @@ def __init__(self, *, console: object, auto_refresh: bool = True) -> None: self._live.start() def _get_renderable(self) -> object: + """Return the current renderable, resolving callables.""" renderable = self._renderable if callable(renderable): return renderable() @@ -151,24 +153,7 @@ def make_display_handle(*, auto_refresh: bool = True) -> object | None: class ActivityIndicator: - """ - Render a live activity indicator for long-running work. - - Parameters - ---------- - label : str, default=ACTIVITY_LABEL_PROCESSING - User-facing activity label. - verbosity : VerbosityEnum - Output verbosity controlling whether live display is shown. - display_handle : object | None, default=None - Optional existing live display handle to reuse. - animated : bool, default=True - Whether to animate the spinner label continuously. - refresh_per_second : float | None, default=None - Optional override for the Rich Live refresh rate. When ``None``, - defaults to one refresh per spinner frame. Lower values reduce - terminal flicker for multi-line live regions. - """ + """Render a live activity indicator for long-running work.""" def __init__( self, @@ -179,6 +164,24 @@ def __init__( animated: bool = True, refresh_per_second: float | None = None, ) -> None: + """ + Initialise indicator state without starting rendering. + + Parameters + ---------- + label : str, default=ACTIVITY_LABEL_PROCESSING + User-facing activity label. + verbosity : VerbosityEnum + Output verbosity controlling whether live display is shown. + display_handle : object | None, default=None + Optional existing live display handle to reuse. + animated : bool, default=True + Whether to animate the spinner label continuously. + refresh_per_second : float | None, default=None + Optional override for the Rich Live refresh rate. When + ``None``, defaults to one refresh per spinner frame. Lower + values reduce terminal flicker for multi-line live regions. + """ self._label = label self._verbosity = verbosity self._content: object | None = None @@ -298,6 +301,7 @@ def _terminal_renderable(self) -> object: return Group(*renderables) def _refresh(self) -> None: + """Refresh the active display handle or live region.""" if self._verbosity is VerbosityEnum.SILENT: return @@ -310,6 +314,7 @@ def _refresh(self) -> None: self._live.refresh() def _refresh_display_handle(self) -> None: + """Update the display handle with HTML or terminal output.""" if self._display_handle is None: return @@ -329,6 +334,7 @@ def _refresh_display_handle(self) -> None: self._display_handle.update(renderable) def _terminal_content(self) -> object | None: + """Return current content as a terminal renderable.""" if self._content is None: return None if is_renderable(self._content): @@ -336,6 +342,7 @@ def _terminal_content(self) -> object | None: return Text(str(self._content)) def _terminal_indicator_line(self) -> Text | None: + """Return the styled spinner/label line, or None if hidden.""" style = resolve_activity_terminal_style(ConsoleManager.get()) if self._running: if self._animated: @@ -347,11 +354,13 @@ def _terminal_indicator_line(self) -> Text | None: return None def _current_frame(self) -> str: + """Return the spinner frame for the elapsed run time.""" elapsed = monotonic() - self._started_at frame_index = int(elapsed / _SPINNER_FRAME_SECONDS) % len(SPINNER_FRAMES) return SPINNER_FRAMES[frame_index] def _render_html(self) -> str: + """Return the full HTML for the notebook indicator.""" content_html = self._html_content() indicator_html = self._html_indicator() @@ -363,6 +372,7 @@ def _render_html(self) -> str: return f'{self._html_style()}
{body}
' def _html_content(self) -> str: + """Return the current content as an HTML fragment.""" if self._content is None: return '' if isinstance(self._content, str): @@ -376,6 +386,7 @@ def _html_content(self) -> str: return f'
{text}
' def _html_indicator(self) -> str: + """Return the spinner/label indicator as an HTML fragment.""" safe_label = html.escape(self._label) if self._running: @@ -403,6 +414,7 @@ def _html_indicator(self) -> str: @staticmethod def _html_style() -> str: + """Return the CSS style block for the notebook indicator.""" keyframes = [] total_frames = len(SPINNER_FRAMES) for index, frame in enumerate(SPINNER_FRAMES): @@ -456,9 +468,11 @@ class _ActivityIndicatorContext(AbstractContextManager[ActivityIndicator]): """Context manager wrapper for ``ActivityIndicator``.""" def __init__(self, *, label: str, verbosity: VerbosityEnum) -> None: + """Create the wrapped activity indicator.""" self._indicator = ActivityIndicator(label, verbosity=verbosity) def __enter__(self) -> ActivityIndicator: + """Start and return the activity indicator.""" self._indicator.start() return self._indicator @@ -468,6 +482,7 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: + """Stop the activity indicator on context exit.""" del exc_type del exc_value del traceback @@ -502,6 +517,7 @@ class NotebookFitStopControl(AbstractContextManager): """Display a Jupyter stop button for fitting runs.""" def __init__(self, *, verbosity: VerbosityEnum) -> None: + """Initialise stop-control state and resolve the kernel id.""" self._verbosity = verbosity self._display_handle: object | None = None self._element_id = f'ed-fit-stop-{uuid.uuid4().hex}' @@ -545,6 +561,7 @@ def close(self) -> None: self._display_handle = None def _can_display(self) -> bool: + """Return whether the stop button can be displayed.""" return ( self._verbosity is not VerbosityEnum.SILENT and in_jupyter() @@ -555,6 +572,7 @@ def _can_display(self) -> bool: ) def _active_html(self) -> str: + """Return the HTML markup for the active stop button.""" return ( '