diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f00ac0..9563faa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,69 +4,89 @@ on: push: pull_request: +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install lint dependencies run: | python -m pip install --upgrade pip - python -m pip install .[dev] + python -m pip install ".[dev]" - name: Lint run: flake8 src tests tools + - name: Type check + run: python -m mypy src/atomref + - name: Validate citation metadata + run: cffconvert --validate - name: Validate packaged registry run: python tools/check_registry.py - - name: Validate notebooks - run: python tools/check_notebooks.py - - name: Check notebook exports - run: python tools/export_notebooks.py --check - name: Check README sync run: python tools/gen_readme.py --check + notebooks-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install notebook dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ".[notebooks]" + - name: Smoke-execute notebooks with Jupyter + timeout-minutes: 20 + run: python tools/check_notebooks.py + docs-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install docs extras run: | python -m pip install --upgrade pip - python -m pip install .[docs] - - name: Export notebooks and README - run: | - python tools/export_notebooks.py --check - python tools/gen_readme.py --check + python -m pip install ".[docs,notebooks]" + - name: Check README sync + run: python tools/gen_readme.py --check - name: Build docs + env: + NO_MKDOCS_2_WARNING: "true" run: mkdocs build --strict + - name: Verify docs did not rewrite notebooks + run: git diff --exit-code -- docs/notebooks test: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install run: | python -m pip install --upgrade pip - python -m pip install .[test] + python -m pip install ".[test]" - name: Test run: pytest build-dist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install build dependencies @@ -78,15 +98,4 @@ jobs: - name: Validate metadata run: python -m twine check dist/* - name: Check packaged files - run: python tools/check_dist.py dist - - name: Install built wheel and smoke-test it - run: | - python -m pip install --force-reinstall --no-deps dist/*.whl - python - <<'PY' - import atomref as ar - - assert ar.get_covalent_radius('C') == 0.76 - assert ar.get_vdw_radius('C') == 1.77 - assert 'atomic_radius' in ar.list_quantities() - assert 'rahm2016' in ar.list_dataset_ids('atomic_radius', usage_role='support') - PY + run: python tools/check_dist.py dist --check-installs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 36c704c..2153aca 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,22 +15,24 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Configure GitHub Pages uses: actions/configure-pages@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install docs extras run: | python -m pip install --upgrade pip - python -m pip install .[docs] - - name: Check generated files - run: | - python tools/export_notebooks.py --check - python tools/gen_readme.py --check + python -m pip install ".[docs,notebooks]" + - name: Check README sync + run: python tools/gen_readme.py --check - name: Build docs + env: + NO_MKDOCS_2_WARNING: "true" run: mkdocs build --strict + - name: Verify docs did not rewrite notebooks + run: git diff --exit-code -- docs/notebooks - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d2c3a..cdd55fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,124 @@ # Changelog +## 0.2.1 - 2026-07-15 + +### Added + +- Complete structured public API documentation with rendered typed signatures, + parameters, returns, raised errors, attributes, examples, and cross-references. +- Repository-level `CITATION.cff` metadata for citing `atomref` directly as + software, without a preferred paper citation, and with the bundled-data + licensing boundary recorded in its abstract. +- Clean built-wheel installation checks for the base package, `notebooks`, and + `all` extras. + +### Changed + +- Renamed the optional `notebook` extra to `notebooks`, which describes the + shipped notebook collection without implying installation of the Jupyter + Notebook server application. +- Made `all` the exact deduplicated union of `test`, `notebooks`, `docs`, and + `dev`, including every optional dependency declared by the project. +- Added Python 3.14 to the test matrix and package classifiers. +- Repositioned the documentation home page and generated README around rapid + installation, first use, scientific provenance, and adoption by downstream + structure-analysis software. +- Rendered the maintained `.ipynb` notebooks directly in MkDocs with committed + Markdown, code, mathematics, text output, and PNG plots. +- Replaced the bespoke notebook execution/export path with bounded, isolated + standard Jupyter workers that execute temporary copies and discard their + results. Kernel startup, cell execution, cleanup, and process exit are all + contained by explicit time limits. +- Clarified that release readiness was reviewed against project criteria rather + than claiming independent external review. +- Corrected citation and documentation wording so `NOTICE.md` is described as + the license, attribution, and DOI record, while exact source and payload + hashes remain identified with the packaged registry metadata. +- Kept the PEP 561 marker and corrected inline annotations so the package passes + strict mypy checking against its minimum supported Python 3.10 target. +- Validate proatomic-density datasets, units, and radii before missing-profile + fallback, and reject boolean radii explicitly. + +### Packaging + +- Declared the renderer, execution client, notebook format library, kernel, and + plotting library in `notebooks`; clean-install validation now verifies that + `all` exactly combines every component extra. +- Included `CITATION.cff` in source distributions and require an exact copy + during artifact validation. +- Updated CI, source-distribution checks, and release preparation for the final + single-source notebook layout, bounded notebook-process lifecycle, and + isolated artifact installations. +- Kept MkDocs below version 2 across documentation and notebook-related extras, + and suppressed Material's MkDocs 2 migration banner in automated strict + builds. +- Build release artifacts from a clean committed-source extraction and reject + nonstandard executable modes on ordinary wheel and source-distribution files. +- Use an explicit safe tar extraction filter where supported when reconstructing + the committed source tree, avoiding Python 3.14's implicit-filter warning. +- Restrict the CI workflow to read-only repository-content permissions. +- Added strict mypy and CFF 1.2 schema validation to CI and the local release + gate, with repository-specific citation checks retained for atomref metadata. +- Updated the checkout and Python-setup GitHub Actions to their current Node 24 + generations. +- Removed generated notebook Markdown, the custom exporter, export-sync tests, + and the duplicate documentation copy of the development plan. +- Clarified the mixed LGPL-3.0-or-later software and CC BY 4.0 bundled-data + licensing boundary in the shipped notice and citation metadata. + +### Scientific behavior + +- No density values, cutoff radii, pairwise modes, selected coordinates, + statuses, packaged scientific data, or other numerical behavior changed. + +## 0.2.0 - 2026-07-14 + +### Added + +- A packaged, immutable neutral H–Lr spherical proatomic-density dataset derived + reproducibly from `atomref-proatoms` 2.0.0 dataset + `pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`, with exact source, basis, license, + hash, and DOI metadata. +- Cached profile retrieval and dependency-free scalar density evaluation with + independent radius and density units, positive-region log–log interpolation, + and a strict 0–20 bohr public radius domain. +- `estimate_proatomic_boundary()` for the stable neutral-proatom divider and + `estimate_promolecular_density_minimum()` for the optional cutoff-bounded, + resolution-limited promolecular line-density minimum proxy. +- `estimate_ias_position()` with explicit `boundary` and `minimum` modes; + `boundary` is the default and minimum mode never silently falls back to it. +- Immutable `IASPositionResult` values with coordinates, explicit diagnostic + statuses, component densities, cutoff/search information, units, and + numerical/data provenance. +- Executed method-selection and feature notebooks with saved outputs and plots. + +### Changed + +- `get_builtin_set()` now dispatches both scalar CSV and shared-grid radial ZIP + datasets through the same registry machinery. Existing scalar policies, + radii values, X–H behavior, and `0.1.x` APIs remain unchanged. +- The package now identifies itself as version `0.2.0` and includes proatomic + density, electron density, interatomic-surface, and IAS discovery keywords. + +### Documentation + +- Added the neutral proatomic-density and pairwise guide, complete + `atomref.proatoms` API reference, exact cutoff/range/unit/status guidance, and + links to the saved release notebooks. +- Updated the home page and architecture description for radial datasets and + the accepted two-mode pairwise API without performing the broader planned + documentation redesign. + +### Packaging + +- Included the deterministic proatomic-density ZIP in wheels and source + distributions and added independent content validation. +- Added an optional `notebook` extra for Matplotlib; runtime dependencies remain + empty. +- Extended CI, notebook checks, distribution-content checks, release checks, + and clean-wheel smoke tests for density evaluation, both pairwise modes, and + dispatcher equivalence. + ## 0.1.4 - 2026-03-15 ### Added diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..1642d00 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,28 @@ +cff-version: 1.2.0 +message: "If you use atomref, please cite this software." +title: "atomref" +type: software +authors: + - family-names: "Chernyshov" + given-names: "Ivan Yu." + email: "ivan.chernyshoff@gmail.com" +version: "0.2.1" +date-released: 2026-07-15 +repository-code: "https://github.com/DeloneCommons/atomref" +url: "https://delonecommons.github.io/atomref/" +license: "LGPL-3.0-or-later" +abstract: >- + A dependency-free-core Python package for curated atomic reference data, + spherical neutral proatomic electron-density profiles, provenance-aware + lookup policies, and pairwise reference-atom boundary estimators. The + bundled neutral proatomic-density snapshot is licensed under CC BY 4.0; see + NOTICE.md for the exact licensing boundary, attribution, and source DOIs. + The exact source commit and SHA-256 hashes are recorded in the packaged + registry metadata. +keywords: + - chemistry + - crystallography + - atomic reference data + - proatomic density + - electron density + - interatomic surfaces diff --git a/DEV_PLAN.md b/DEV_PLAN.md index 94cdaac..1ad4281 100644 --- a/DEV_PLAN.md +++ b/DEV_PLAN.md @@ -1,33 +1,138 @@ -# Development plan +# atomref development plan -## Current status (implemented in the `0.1.x` line) +> **Status:** No active near-term development plan +> **Plan lifecycle state:** `CLOSED` +> **Current completed development line:** `atomref 0.2.1` +> **Scientific data source:** `atomref-proatoms 2.0.0`, dataset `pbe0_sfx2c_dyallv4z_h-lr_spherical_v2` -- stable element metadata -- curated covalent, van der Waals, and atomic-radius support datasets -- explicit provenance and coverage metadata -- generic value-policy core plus radii and X–H convenience wrappers -- substitution and linear transfer -- custom element-indexed scalar sets -- policy-backed transfer sources -- nested-policy safeguards, transfer-depth tracking, and cycle detection -- provisional X–H support via `csd_legacy_xh_cno`, `XHPolicy`, and - `DEFAULT_XH_POLICY` +## 1. Current state -## Planned for `0.2.x` +The `0.2.x` development cycle is complete. Its scientific implementation, +documentation, notebooks, packaging, and release preparation have been +reviewed against the project's documented criteria and accepted for release. -- broader X–H datasets and policies -- experimental plus computational support sets -- pairwise helper logic such as reference sums and normalization schemes -- restoration of incomplete experimental data from broader-support predictors +`atomref` is now a small, dependency-free-core package providing cited atomic +reference data and frozen spherical free-atom electron densities for +crystallographic, quantum-chemical, and molecular-structure algorithms. -## Longer-term design ideas +The completed package scope includes: -- radial atomic reference functions -- simple proto-density support based on spherically averaged atomic data +- scalar radii and X–H reference data with explicit provenance and policy + behavior; +- generic discovery and loading of packaged scalar and radial datasets; +- neutral spherical proatomic-density profiles for H–Lr; +- scalar density evaluation over the documented `0–20 bohr` domain; +- a stable neutral-proatom boundary estimate; +- an optional resolution-limited promolecular line-density minimum; +- complete public API documentation, adoption-oriented user documentation, + directly rendered notebooks, and validated release artifacts. -## Possible future directions +There is currently **no active implementation stage, scheduled feature release, +or close-term roadmap**. Routine maintenance may address confirmed defects, +compatibility problems, documentation corrections, packaging issues, or +security concerns. Any substantial feature requires a new, separately reviewed +development plan. -- more radii sets -- uncertainty and confidence flags -- ion-specific or atom-type-specific domains -- density-derived radii and related reference transforms +Completed `0.2.x` implementation history belongs in the changelog, Git history, +release records, documentation, and tests rather than in this file. + +## 2. Durable project constraints + +Future work should preserve the following principles unless the repository owner +explicitly approves a revised contract: + +- Keep the core runtime pure Python, without required third-party dependencies + or runtime network access. +- Preserve explicit dataset identity, provenance, licensing, units, supported + ranges, and charge/state scope. +- Do not silently substitute elements, correlate missing profiles, infer ionic + states, or apply scalar fallback/transfer policies to radial profiles. +- Keep packaged datasets integrated through the existing registry and + `get_builtin_set()` machinery rather than adding unrelated loading paths. +- Preserve the distinction between the stable proatomic-boundary mode and the + optional promolecular-minimum mode. Neither should be represented as an exact + molecular QTAIM boundary or critical point. +- Avoid speculative frameworks and abstractions. Add dependencies, public + objects, files, or architectural layers only when required by demonstrated + use cases. +- Preserve scientific and numerical behavior across documentation, packaging, + and maintenance-only releases unless a confirmed defect is documented and + protected by regression tests. +- Keep `docs/index.md` as the source of `README.md`, maintain one source for each + notebook, and continue validating installed wheel and source-distribution + contents. + +## 3. Current known scope limits + +The present implementation is intentionally limited: + +- proatomic profiles are neutral, spherical reference densities for H–Lr; +- scalar profile evaluation is supported only within `0–20 bohr`; +- no ionic, fractional-charge, environment-dependent, or self-consistent + stockholder model is provided; +- pairwise boundary and minimum results are reference-atom proxies, not + molecular-density QTAIM results; +- no vectorized array API, three-dimensional density-grid generator, periodic + image summation, or molecular-density construction is included; +- X–H support retains its documented dataset and parent-element limitations. + +These are accepted scope boundaries, not active defects. + +## 4. Possible future directions + +The following ideas are non-binding. They do not authorize implementation and +have no assigned order, milestone, or version. + +### Atomic data and states + +- Add ionic proatomic datasets with an evidence-based, explicit state-selection + design. +- Add further scalar or radial atomic reference properties when a concrete + downstream use case and suitable source data exist. +- Support user-supplied radial datasets while preserving the same metadata, + validation, and evaluation contracts. + +### Numerical and spatial APIs + +- Add optional NumPy-based vectorized profile evaluation if profiling and real + workloads justify it. +- Generate single-atom and promolecular densities on three-dimensional grids. +- Design nonperiodic and periodic grid support together, including triclinic + cells, explicit units, memory/chunking policy, and correct periodic-image + enumeration. +- Add lightweight stockholder-initialization or common crystallographic-grid + helpers where they provide clear interoperability value. + +### Scientific validation + +- Build a curated molecular-density/QTAIM benchmark comparing midpoint, + equal-proatom boundary, and promolecular-minimum coordinates. +- Reassess cutoff and practical-resolution policies only from benchmark + evidence, without silently changing established semantics. +- Optimize repeated pair evaluation only after downstream profiling identifies + a meaningful bottleneck. + +### Maintenance and engineering + +- Introduce static type checking or small internal cleanups when they provide + clear maintenance value. +- Strengthen mixed-license and artifact validation as additional datasets are + added. +- Revisit registry or storage abstractions only when a new real data family + cannot be represented cleanly by the current design. + +## 5. Starting future development + +Before implementing any direction above: + +1. Select one concrete problem and document the user or scientific need. +2. Define the data source, provenance, scientific meaning, supported domain, + public API, compatibility constraints, dependencies, and explicit + exclusions. +3. Prepare a small staged plan with acceptance tests and release criteria. +4. Review and approve that plan before coding. +5. Mark the new plan `ACTIVE`; until then, this file remains a closed, + non-actionable roadmap. + +Do not interpret the possible directions in this file as committed work or as +permission to begin implementation. diff --git a/NOTICE.md b/NOTICE.md index 01f1cf1..6d082f4 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,12 +1,37 @@ # atomref -atomref is a Python library for curated atomic reference data and transfer -policies for geometry and structure-analysis algorithms. +atomref is a Python library for curated atomic reference data, spherical +proatomic electron-density profiles, and provenance-aware transfer policies +for geometry and structure-analysis algorithms. Copyright (c) 2026 Ivan Chernyshov -License: LGPL-3.0-or-later (see LICENSE and COPYING) +Except for the separately licensed material identified below, the atomref +software and accompanying repository content are licensed under +LGPL-3.0-or-later (see LICENSE and COPYING). ## Third-party material -The initial scaffold reuses and adapts data tables and design ideas from the -Delone Commons `molcryst` repository, also authored by Ivan Chernyshov. +Some data tables and design ideas were adapted from earlier chemistry and +structure-analysis software authored by Ivan Chernyshov. + +### atomref-proatoms data + +`proatomic_density_neutral.zip` is a neutral H–Lr (Z=1–103), 20-bohr-truncated +packaged snapshot derived from: + +Ivan Yu. Chernyshov, *atomref-proatoms: spherical atomic and ionic reference +densities*, release 2.0.0, dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`. + +The released source data and metadata are licensed under Creative Commons +Attribution 4.0 International (CC BY 4.0): +https://creativecommons.org/licenses/by/4.0/legalcode + +- Concept DOI: 10.5281/zenodo.21291021 +- Version DOI for the immutable 2.0.0 archive: 10.5281/zenodo.21291022 +- Source repository: https://github.com/DeloneCommons/atomref-proatoms + +atomref selected the 103 neutral profiles, retained source rows through the +first grid point above 20 bohr for endpoint bracketing, and repackaged them as a +deterministic single-member ZIP. This data license is separate from atomref's +LGPL-3.0-or-later code license. diff --git a/README.md b/README.md index c77838b..a3bbced 100644 --- a/README.md +++ b/README.md @@ -8,159 +8,215 @@ Documentation: https://delonecommons.github.io/atomref -`atomref` is a small pure-Python package for **curated atomic reference data** -and **provenance-aware lookup policies** used by geometry and -structure-analysis algorithms. - -It is not meant to be yet another periodic-table encyclopedia. The package is -for code that needs stable atomic reference values with explicit provenance, -clear fallback behavior, and honest handling of incomplete preferred datasets. - -What you get in the current release line: - -- stable element metadata, -- curated named radii sets, -- provisional X–H bond-length support for hydrogen-normalisation workflows, -- dataset provenance and coverage metadata, -- deterministic lookup policies, -- substitution and linear transfer from support datasets or policies into target datasets, -- guarded nested policy-backed transfers with explicit transfer depth, - conservative fit/prediction controls, and cycle detection, -- user-defined custom element-indexed scalar sets. - -## Core terms - -`atomref` uses a small vocabulary on purpose. - -- **quantity** — the operational property family being requested, such as - `covalent_radius`, `van_der_waals_radius`, `atomic_radius`, or - `xh_bond_length`. -- **domain** — the key space used to index that quantity. In the current - runtime, the supported domain is `element`, meaning lookups are keyed by an - element symbol. -- **dataset** — one curated named table inside a quantity, such as - `cordero2008`, `alvarez2013`, or `csd_legacy_xh_cno`. -- **policy** — the ordered rule set that decides what value to return when the - preferred dataset is incomplete. - -The metadata layer already records `domain` explicitly because the package is -built for later extension, but the current runtime intentionally keeps the -implementation narrow and stable: **the current runtime resolves only -element-domain scalar values**. - -## Why this exists - -Scientific software often wants a complete lookup table, but the best dataset -for the job is rarely complete. `atomref` makes that situation explicit. -Instead of hiding ad hoc defaults inside algorithm code, you choose a target -set, describe how missing values may be restored, and keep provenance on what -was actually returned. - -The built-in default behavior is intentionally simple and practical: - -- **Cordero covalent radii** (`cordero2008`) are the preferred covalent target - set, with missing values substituted from the **legacy CSD covalent radii** - (`csd_legacy_cov`). -- **Alvarez van der Waals radii** (`alvarez2013`) are the preferred vdW target - set, with missing values restored from the **Rahm isodensity atomic radii** - (`rahm2016`) through a fitted linear transfer. -- **CSD/ConQuest hydrogen-normalisation defaults** (`csd_legacy_xh_cno`) are a - provisional sparse X–H target set for `C`, `N`, and `O`, with other parent - elements inferred from **Cordero covalent radii** through a fitted linear - transfer. - -Nested policy predictors are supported too. `LinearTransfer` separates -**fit-time** use of nested predictor values from **prediction-time** use. By default, the fit may use only direct nested -values, while the final requested element may still use one additional -nested completion step. That is a useful compromise for workflows such as -provisional X–H inference from a chosen covalent-radii policy. - -## Quick example +`atomref` provides cited atomic properties and frozen spherical free-atom +electron densities through a small Python API for crystallographic, +quantum-chemical, and molecular-structure algorithms. + +Use it when your software needs dependable atomic radii, X–H reference lengths, +neutral proatomic densities, or pairwise reference-atom boundaries without +embedding another project-local table and its fallback rules. The core runtime +is pure Python and has **no required third-party dependencies**. + +## Install and get a useful result + +```bash +pip install atomref +``` + +```python +import atomref as ar + +covalent_c = ar.get_covalent_radius("C") +xh_n = ar.get_xh_bond_length("N") +rho_o = ar.get_proatomic_density("O", 0.75) +boundary = ar.estimate_proatomic_boundary("C", "O", 1.43) +minimum = ar.estimate_promolecular_density_minimum("C", "O", 1.43) +``` + +The scalar results above are in documented units: radii and X–H lengths use +angstrom, while the density call returns electron/bohr³ by default. Pairwise +coordinates are measured from the first atom toward the second. For this C–O +example, `boundary` is the stable equal-proatom divider and `minimum` is the +optional, resolution-limited minimum of the summed promolecular line density. + +[Start with the quickstart](https://delonecommons.github.io/atomref/guide/quickstart/) +or open the [complete API reference](https://delonecommons.github.io/atomref/api/). + +## What it solves + +- **Bond and contact geometry:** select named covalent and van der Waals radii + instead of scattering constants through structure code. +- **Hydrogen normalization:** obtain provisional, provenance-aware X–H target + lengths keyed by the parent element. +- **Incomplete reference tables:** use explicit substitution or fitted transfer + policies and inspect how a value was obtained. +- **Free-atom density sampling:** evaluate immutable neutral H–Lr spherical + profiles with explicit coordinate and density units over a strict 0–20 bohr + domain. +- **Pairwise reference-atom models:** choose a stable proatomic boundary or an + explicitly cutoff-bounded promolecular-minimum proxy without presenting + either as an exact molecular QTAIM surface. + +Most convenience functions come in two forms: ```pycon >>> import atomref as ar ->>> ar.get_covalent_radius("C") -0.76 ->>> ar.get_vdw_radius("O") -1.5 ->>> ar.get_xh_bond_length("N") -1.015 ->>> lookup = ar.lookup_vdw_radius("Pm") ->>> lookup.value +>>> ar.get_vdw_radius("Pm") 2.8972265395148358 ->>> lookup.source -'transfer_linear' ->>> lookup.transfer_depth -1 ->>> lookup.resolved_from +>>> result = ar.lookup_vdw_radius("Pm") +>>> result.source, result.transfer_depth +('transfer_linear', 1) +>>> result.resolved_from (DatasetRef(quantity='atomic_radius', set_id='rahm2016'),) ``` -`get_*` returns only the number. `lookup_*` returns a `LookupResult` that also -records where the value came from, whether a transfer model or policy source was -involved, and how many transfer steps were needed (`transfer_depth`). +`get_*` returns the selected number. `lookup_*` returns a typed `LookupResult` +with the source, supporting datasets, placeholder state, fit information, and +transfer depth. -You can inspect the packaged quantity and dataset catalog directly: +## Why adopt `atomref`? -```pycon ->>> import atomref as ar ->>> ar.list_quantities() -('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length') ->>> ar.get_quantity_info("xh_bond_length") -QuantityInfo(quantity='xh_bond_length', domain='element', units='angstrom', description='Element-indexed reference X-H bond lengths keyed by parent element X and intended for hydrogen-position normalisation or related geometry workflows.') ->>> [info.ref.set_id for info in ar.list_dataset_infos("van_der_waals_radius", usage_role="target")] -['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] +A local constants file is easy to start and difficult to maintain. It usually +accumulates uncited values, silent replacements for missing elements, ambiguous +units, and behavior that cannot be reviewed independently of the consuming +algorithm. `atomref` keeps those concerns in one versioned layer: + +- every packaged dataset has a stable identifier, coverage metadata, and + bibliographic provenance; +- lookup rules are explicit and deterministic rather than hidden in callers; +- direct, substituted, fitted, fallback, placeholder, and missing results stay + distinguishable; +- public types, units, valid ranges, and failure behavior are documented; +- packaged data and attribution are checked in both wheel and source + distributions; +- the dependency-free runtime can be embedded in lightweight scientific tools. + +The package does not claim that one table or policy is universally correct. It +makes the selected reference and the assumptions around it visible. + +## Installation choices + +The base install is sufficient for every runtime API: + +```bash +pip install atomref ``` -You can also load a packaged set directly: +Install the `notebooks` extra to execute or render the shipped Jupyter examples +and their plots: -```pycon ->>> import atomref as ar ->>> vdw = ar.get_radii_set("van_der_waals", "alvarez2013") ->>> vdw.get("O") -1.5 ->>> xh = ar.get_xh_set("csd_legacy_xh_cno") ->>> xh.get("C") -1.089 +```bash +pip install "atomref[notebooks]" ``` -## Notebook walkthroughs +Install every optional dependency declared by the project with: + +```bash +pip install "atomref[all]" +``` -The repository ships example notebooks for the main workflows. In the -documentation they are also available as rendered Markdown pages, so users can -read them without opening Jupyter first. +`all` is the exact union of `test`, `notebooks`, `docs`, and `dev`, so it is +appropriate for a complete contributor or release environment. See the +[installation guide](https://delonecommons.github.io/atomref/guide/install/) +for the narrower groups. + +## Data, provenance, and scientific scope + +The scalar catalog includes named covalent, van der Waals, atomic-isodensity, +and provisional X–H datasets. Registry metadata separates the requested +quantity from scientific classification and from whether a dataset is a direct +target or transfer support. + +The neutral proatomic profiles are a deterministic packaged snapshot of the +`atomref-proatoms` 2.0.0 dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`. They cover H–Lr and record the PBE0, +self-consistent spherical fractional-occupation UKS, spin-free one-electron +X2C, and dyall-v4z definition, source hashes, CC BY 4.0 data license, and both +concept and version-specific DOIs. + +These profiles are isolated, neutral, spherical reference atoms—not molecular +electron densities. Density evaluation is scalar, and the public radius domain +is exactly 0–20 bohr. Pairwise `boundary` mode is the stable default. Optional +`minimum` mode searches only where both proatoms remain above +`1e-4 electron/bohr^3`, has a declared `0.01 bohr` resolution, and may return a +typed diagnostic without a coordinate. + +- [Dataset catalog and provenance](https://delonecommons.github.io/atomref/datasets/) +- [Proatomic-density and pairwise scientific guide](https://delonecommons.github.io/atomref/guide/proatomic_density/) +- [Policy guide](https://delonecommons.github.io/atomref/guide/policies/) +- [Explicit non-goals](https://delonecommons.github.io/atomref/guide/non_goals/) + +## Citation and licenses + +Cite `atomref` as software using the repository-level +[`CITATION.cff`](https://github.com/DeloneCommons/atomref/blob/main/CITATION.cff). +There is no preferred paper citation; the versioned software release is the +canonical object to cite. + +Except for separately identified material, the software and accompanying +repository content are licensed under LGPL-3.0-or-later. The bundled neutral +proatomic-density snapshot is licensed separately under CC BY 4.0. +[`NOTICE.md`](https://github.com/DeloneCommons/atomref/blob/main/NOTICE.md) +records the exact boundary, attribution, and source DOIs. The packaged registry +metadata records the exact source commit and SHA-256 hashes. + +## Executable notebook documentation + +The documentation renders the actual committed `.ipynb` files directly, +including Markdown, code, mathematics, saved text, and saved PNG plots. Site +builds do not execute or rewrite them; separate bounded Jupyter workers verify +temporary copies without retaining their results. - [Notebook overview](https://delonecommons.github.io/atomref/guide/notebooks/) - [Quickstart notebook](https://delonecommons.github.io/atomref/notebooks/01-quickstart/) - [Policies and assessment notebook](https://delonecommons.github.io/atomref/notebooks/02-policies-and-assessment/) - [Custom sets and discovery notebook](https://delonecommons.github.io/atomref/notebooks/03-custom-sets-and-discovery/) +- [IAS method-selection study](https://delonecommons.github.io/atomref/notebooks/04-ias-method-selection-study/) +- [Proatomic density and IAS workflows](https://delonecommons.github.io/atomref/notebooks/05-proatomic-density-and-ias/) + +## Dataset and policy discovery -## Relationship to Delone Commons +The lower-level registry is available when an application needs to choose or +report an exact source: + +```pycon +>>> import atomref as ar +>>> ar.list_quantities() +('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length', 'proatomic_density') +>>> [info.ref.set_id for info in ar.list_dataset_infos( +... "van_der_waals_radius", usage_role="target" +... )] +['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] +>>> profile_info = ar.get_proatomic_density_set_info() +>>> profile_info.ref.set_id +'pbe0_sfx2c_dyallv4z_h-lr_neutral_v2' +``` -`atomref` is designed as a standalone package, but within Delone Commons it is -primarily intended to support chemistry-aware packages such as: +Custom element-indexed scalar sets can participate in the same policy layer. +Radial profiles deliberately do not: no scalar `ValuePolicy`, neighboring- +element substitution, or fitted correlation is applied to density data. -- `molcryst`, for covalent-bond detection, contact analysis, and hydrogen workflows, -- future `chemvoro`, for chemistry-aware contact and hydrogen workflows. +## Use in scientific software -By contrast, `pyvoro2` and `pbcgraph` are intentionally general mathematical -packages and are not direct consumers of `atomref`. +`atomref` is a standalone package for physicochemical and structural-analysis +software that needs curated atomic properties, proatomic densities, or explicit +reference-data policy. Purely mathematical packages can remain independent of +those choices until a consuming application needs atomic context. -## Data curation and developer tools +## Maintainer checks -The repository also ships small maintenance tools. The most important ones are: +The repository keeps a small set of release tools: -- `python tools/check_registry.py` — validate curated registry metadata against - packaged CSV tables, -- `python tools/check_notebooks.py` — execute notebook code cells, -- `python tools/export_notebooks.py` — turn notebooks into Markdown pages for - the docs, -- `python tools/gen_readme.py` — regenerate `README.md` from this page, -- `python tools/release_check.py` — run the full release-preparation checklist, - including linting, tests, docs, builds, and artifact validation. +- `python tools/check_registry.py` validates registry metadata against every + packaged scalar and radial payload; +- `python tools/check_notebooks.py` smoke-executes each temporary notebook copy + in its own bounded standard Jupyter process, then discards the results; +- `python tools/gen_readme.py` regenerates this README from `docs/index.md`; +- `python tools/release_check.py` runs lint, tests, strict docs, distribution + checks, and clean artifact-installation checks. See the [tools README](https://github.com/DeloneCommons/atomref/blob/main/tools/README.md) -for a short description of each script. +for the maintainer-only data snapshot workflow and command details. --- diff --git a/docs/api/atomref.md b/docs/api/atomref.md index 3536e34..185f78e 100644 --- a/docs/api/atomref.md +++ b/docs/api/atomref.md @@ -4,3 +4,73 @@ The top-level package re-exports the main user-facing API so that most code can simply do `import atomref as ar`. ::: atomref + options: + members: + - __version__ + - Element + - canonicalize_element_symbol + - get_element + - iter_elements + - is_valid_element_symbol + - BuiltinSet + - CoverageInfo + - DatasetInfo + - DatasetRef + - ElementRadialSet + - ElementScalarSet + - QuantityInfo + - Reference + - get_builtin_set + - get_dataset_info + - get_quantity_info + - list_dataset_ids + - list_dataset_infos + - list_quantities + - LinearFit + - LinearTransfer + - SubstitutionTransfer + - LookupResult + - ValuePolicy + - lookup_value + - get_value + - BOHR_TO_ANGSTROM + - DEFAULT_PROATOMIC_DENSITY_SET + - PROATOMIC_TAIL_CUTOFF + - IAS_MINIMUM_RESOLUTION_BOHR + - IASPositionResult + - ProatomicDensityProfile + - ProatomicDensitySet + - estimate_proatomic_boundary + - estimate_promolecular_density_minimum + - estimate_ias_position + - list_proatomic_density_sets + - list_proatomic_density_set_infos + - get_proatomic_density_set + - get_proatomic_density_set_info + - get_proatomic_density_profile + - get_proatomic_density + - RadiiPolicy + - RadiiElementAssessment + - RadiiPolicyAssessment + - DEFAULT_COVALENT_POLICY + - DEFAULT_VDW_POLICY + - list_radii_sets + - list_radii_set_infos + - get_radii_set + - get_radii_set_info + - lookup_covalent_radius + - get_covalent_radius + - lookup_vdw_radius + - get_vdw_radius + - assess_radii_policy + - XHPolicy + - DEFAULT_XH_POLICY + - list_xh_sets + - list_xh_set_infos + - get_xh_set + - get_xh_set_info + - lookup_xh_bond_length + - get_xh_bond_length + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/elements.md b/docs/api/elements.md index 2f066c7..761d053 100644 --- a/docs/api/elements.md +++ b/docs/api/elements.md @@ -1,7 +1,17 @@ # atomref.elements -Element identity is intentionally minimal in the current implementation: -atomic number, symbol, and name. The module also contains the canonicalization helpers used throughout the +Element identity is intentionally minimal: atomic number, symbol, and name. +The module also contains the canonicalization helpers used throughout the package. ::: atomref.elements + options: + members: + - Element + - canonicalize_element_symbol + - is_valid_element_symbol + - get_element + - iter_elements + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 0000000..cf7d81b --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1,20 @@ +# atomref.errors + +`atomref` distinguishes missing scientific data from invalid configuration or +malformed packaged data. Ordinary lookup misses use `None` or a missing +[`LookupResult`][atomref.LookupResult]; catchable operational failures use the +exceptions below. + +The exceptions are imported from `atomref.errors` and are deliberately not +re-exported from the top-level package. + +::: atomref.errors + options: + members: + - AtomrefError + - DatasetError + - MissingValueError + - PolicyError + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/index.md b/docs/api/index.md index f56eb7c..52781a2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -8,26 +8,36 @@ documented because they expose the actual data model behind the package. ## Common tasks -- get a single value: use `get_covalent_radius(...)`, `get_vdw_radius(...)`, or - `get_xh_bond_length(...)` -- inspect provenance: use `lookup_covalent_radius(...)`, - `lookup_vdw_radius(...)`, `lookup_xh_bond_length(...)`, or the generic - `lookup_value(...)` -- browse packaged datasets: use `list_quantities()`, `get_quantity_info(...)`, - `list_dataset_infos(...)`, `list_radii_set_infos(...)`, or - `list_xh_set_infos(...)` -- load a packaged set directly: use `get_builtin_set(...)`, `get_radii_set(...)`, - or `get_xh_set(...)` -- define a custom set: use `ElementScalarSet.from_mapping(...)` -- define transfer-backed lookup behavior: use `ValuePolicy`, `RadiiPolicy`, - `XHPolicy`, `SubstitutionTransfer`, and `LinearTransfer` +- get a single value: use + [`get_covalent_radius()`][atomref.get_covalent_radius], + [`get_vdw_radius()`][atomref.get_vdw_radius], or + [`get_xh_bond_length()`][atomref.get_xh_bond_length] +- evaluate a neutral profile or pairwise estimate: use + [`get_proatomic_density()`][atomref.get_proatomic_density], + [`estimate_proatomic_boundary()`][atomref.estimate_proatomic_boundary], or + [`estimate_promolecular_density_minimum()`][atomref.estimate_promolecular_density_minimum] +- inspect provenance with the corresponding `lookup_*` function or the generic + [`lookup_value()`][atomref.lookup_value] +- browse packaged datasets with [`list_quantities()`][atomref.list_quantities], + [`get_quantity_info()`][atomref.get_quantity_info], or a quantity-specific + metadata-listing helper +- load a packaged set directly with + [`get_builtin_set()`][atomref.get_builtin_set] +- define a custom set with + [`ElementScalarSet.from_mapping()`][atomref.ElementScalarSet.from_mapping] +- configure transfer-backed lookup with [ValuePolicy][atomref.ValuePolicy], + [RadiiPolicy][atomref.RadiiPolicy], [XHPolicy][atomref.XHPolicy], + [SubstitutionTransfer][atomref.SubstitutionTransfer], and + [LinearTransfer][atomref.LinearTransfer] ## Module reference - [Top-level package](atomref.md) +- [Exceptions](errors.md) - [Elements](elements.md) - [Registry and packaged datasets](registry.md) - [Transfer models](transfer.md) - [Generic policy core](policy.md) +- [Proatomic density and pairwise estimates](proatoms.md) - [Radii API](radii.md) - [X–H API](xh.md) diff --git a/docs/api/policy.md b/docs/api/policy.md index 29b4142..ed71759 100644 --- a/docs/api/policy.md +++ b/docs/api/policy.md @@ -5,10 +5,10 @@ X–H-specific convenience APIs. Use it when you want to work directly with the shared value-selection engine: -- `ValuePolicy` — generic element-domain policy configuration, -- `lookup_value(...)` — resolve one value together with provenance, -- `get_value(...)` — resolve only the numeric value, -- `LookupResult` — the structured result object returned by the resolver. +- [ValuePolicy][atomref.ValuePolicy] — generic element-domain configuration, +- [`lookup_value()`][atomref.lookup_value] — one value with provenance, +- [`get_value()`][atomref.get_value] — only the numeric value, +- [LookupResult][atomref.LookupResult] — the structured resolver result. A few practical notes: @@ -24,3 +24,13 @@ A few practical notes: wrapper policies such as `RadiiPolicy` and `XHPolicy`. ::: atomref.policy + options: + members: + - LookupSource + - LookupResult + - ValuePolicy + - lookup_value + - get_value + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/proatoms.md b/docs/api/proatoms.md new file mode 100644 index 0000000..9d99959 --- /dev/null +++ b/docs/api/proatoms.md @@ -0,0 +1,44 @@ +# atomref.proatoms + +This module exposes the neutral-density and two-mode pairwise API: + +- [ProatomicDensityProfile][atomref.ProatomicDensityProfile] and + [ProatomicDensitySet][atomref.ProatomicDensitySet] for immutable radial data; +- [`get_proatomic_density()`][atomref.get_proatomic_density] for scalar + positive-region log-log evaluation; +- [`estimate_proatomic_boundary()`][atomref.estimate_proatomic_boundary] for + the stable default divider; +- [`estimate_promolecular_density_minimum()`][atomref.estimate_promolecular_density_minimum] + for the optional cutoff-bounded, resolution-limited minimum proxy; +- [`estimate_ias_position()`][atomref.estimate_ias_position] for explicit mode + dispatch; +- [IASPositionResult][atomref.IASPositionResult] for coordinates, statuses, + cutoff/search diagnostics, + units, and provenance. + +See the [proatomic-density guide](../guide/proatomic_density.md) for source +identity, units, the 20-bohr range, fixed cutoff, mode selection, statuses, and +limitations. + +::: atomref.proatoms + options: + members: + - DEFAULT_PROATOMIC_DENSITY_SET + - BOHR_TO_ANGSTROM + - PROATOMIC_TAIL_CUTOFF + - IAS_MINIMUM_RESOLUTION_BOHR + - IASPositionResult + - ProatomicDensitySet + - ProatomicDensityProfile + - list_proatomic_density_sets + - list_proatomic_density_set_infos + - get_proatomic_density_set_info + - get_proatomic_density_set + - get_proatomic_density_profile + - get_proatomic_density + - estimate_proatomic_boundary + - estimate_promolecular_density_minimum + - estimate_ias_position + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/radii.md b/docs/api/radii.md index ff5e214..992840e 100644 --- a/docs/api/radii.md +++ b/docs/api/radii.md @@ -6,3 +6,24 @@ It provides radii policies, packaged radii-set discovery, lookup helpers, and policy-assessment reports. ::: atomref.radii + options: + members: + - RadiiKind + - RadiiSet + - RadiiPolicy + - RadiiElementAssessment + - RadiiPolicyAssessment + - DEFAULT_COVALENT_POLICY + - DEFAULT_VDW_POLICY + - list_radii_sets + - list_radii_set_infos + - get_radii_set_info + - get_radii_set + - lookup_covalent_radius + - get_covalent_radius + - lookup_vdw_radius + - get_vdw_radius + - assess_radii_policy + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/registry.md b/docs/api/registry.md index 9c41653..82afddc 100644 --- a/docs/api/registry.md +++ b/docs/api/registry.md @@ -3,17 +3,44 @@ This module contains the packaged data model. If you want to understand how `atomref` classifies datasets, how aliases are -resolved, or how built-in CSV tables are turned into typed in-memory objects, -this is the key module to read. +resolved, or how built-in scalar CSV and radial ZIP/CSV payloads become typed +in-memory objects, this is the key module to read. The most important registry ideas are: - **quantity** — the operational property family, - **domain** — the key space used to index that quantity, -- **dataset** — one curated named table inside the quantity. +- **dataset** — one curated named source payload inside the quantity. In the current runtime, the implemented lookup domain is `element`. The registry still stores `domain` explicitly because the metadata design is meant to stay reusable as the package grows. +[`get_builtin_set()`][atomref.get_builtin_set] dispatches `element_scalar_csv` and +`element_radial_csv_zip` storage and returns the `BuiltinSet` union. Policy +consumers explicitly narrow that result to `ElementScalarSet`; radial profiles +do not participate in scalar policy or transfer behavior. + ::: atomref.registry + options: + members: + - QuantityId + - DomainId + - DatasetRef + - Reference + - CoverageInfo + - QuantityInfo + - DatasetInfo + - ElementScalarSet + - ElementRadialSet + - BuiltinSet + - ScalarDatasetLike + - list_quantities + - get_quantity_info + - list_dataset_ids + - list_dataset_infos + - get_dataset_info + - get_builtin_set + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/transfer.md b/docs/api/transfer.md index 17e07ad..1815273 100644 --- a/docs/api/transfer.md +++ b/docs/api/transfer.md @@ -5,8 +5,8 @@ sources. In the current runtime the built-in models are: -- direct substitution (`SubstitutionTransfer`), -- one-predictor linear transfer (`LinearTransfer`). +- direct substitution ([SubstitutionTransfer][atomref.SubstitutionTransfer]), +- one-predictor linear transfer ([LinearTransfer][atomref.LinearTransfer]). A transfer source may be: @@ -37,3 +37,14 @@ against a partial covalent-radii policy that is itself completed from a broader support set. ::: atomref.transfer + options: + members: + - TransferValueSource + - SupportsValuePolicy + - LinearFit + - SubstitutionTransfer + - LinearTransfer + - TransferModel + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/api/xh.md b/docs/api/xh.md index f96db27..cf21a6b 100644 --- a/docs/api/xh.md +++ b/docs/api/xh.md @@ -1,12 +1,11 @@ # atomref.xh -This module provides the provisional X–H bond-length helpers available in the -current release line. +This module provides focused X–H bond-length helpers. It is intentionally narrow: - one packaged sparse target dataset, `csd_legacy_xh_cno`, -- one wrapper policy, `XHPolicy`, +- one wrapper policy, [XHPolicy][atomref.XHPolicy], - convenience helpers for listing packaged X–H sets and resolving X–H values. The built-in quantity is keyed by the **parent element `X`** in `X–H` and is @@ -19,6 +18,21 @@ In the default policy: - other parent elements may be inferred from `cordero2008`, - policy-backed predictors are supported as well, with conservative nested-fit defaults and one additional nested prediction step allowed by default, -- fuller X–H literature support is planned for `0.2.x`. +- the API does not infer a rigorous molecular bond length or perform atom + typing beyond the parent-element policy. ::: atomref.xh + options: + members: + - XHSet + - XHPolicy + - DEFAULT_XH_POLICY + - list_xh_sets + - list_xh_set_infos + - get_xh_set_info + - get_xh_set + - lookup_xh_bond_length + - get_xh_bond_length + filters: + - "!^_[^_]" + - "!^__post_init__$" diff --git a/docs/assets/ias-method-study/c-o-method-comparison.png b/docs/assets/ias-method-study/c-o-method-comparison.png new file mode 100644 index 0000000..3bed37c Binary files /dev/null and b/docs/assets/ias-method-study/c-o-method-comparison.png differ diff --git a/docs/assets/ias-method-study/cutoff-radii.png b/docs/assets/ias-method-study/cutoff-radii.png new file mode 100644 index 0000000..babd718 Binary files /dev/null and b/docs/assets/ias-method-study/cutoff-radii.png differ diff --git a/docs/assets/ias-method-study/li-li-symmetry.png b/docs/assets/ias-method-study/li-li-symmetry.png new file mode 100644 index 0000000..e690b25 Binary files /dev/null and b/docs/assets/ias-method-study/li-li-symmetry.png differ diff --git a/docs/datasets/covalent_radius.md b/docs/datasets/covalent_radius.md index 5e022fd..96fee51 100644 --- a/docs/datasets/covalent_radius.md +++ b/docs/datasets/covalent_radius.md @@ -6,7 +6,7 @@ legacy support dataset. ## Cordero covalent radii (`cordero2008`) -This is the main covalent-radius target set in the current release line. +This is the default built-in covalent-radius target set. - **What it is:** a broad covalent-radius compilation based mainly on crystallographic bond distances. diff --git a/docs/datasets/index.md b/docs/datasets/index.md index d3b2951..d48d4ed 100644 --- a/docs/datasets/index.md +++ b/docs/datasets/index.md @@ -30,9 +30,30 @@ or `list_xh_sets(...)`. If you want the packaged values themselves, use `get_builtin_set(...)`, `get_radii_set(...)`, or `get_xh_set(...)`. +## Neutral proatomic-density snapshot + +The `proatomic_density` quantity currently contains the dataset +`pbe0_sfx2c_dyallv4z_h-lr_neutral_v2`: a truncated neutral H–Lr (Z = 1–103) +packaged snapshot of the `atomref-proatoms` 2.0.0 dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`. Its public radial domain is 0–20 bohr; +one original source point above 20 bohr is retained only to bracket that limit. +The native density unit is electron/bohr³. + +The profiles use PBE0, self-consistent spherical fractional-occupation UKS, +spin-free one-electron X2C, and the dyall-v4z basis. These are method-, basis-, +state-, and sphericalization-defined reference densities, not unique +basis-independent atomic observables. Detailed state and generation metadata +remain in the exact upstream 2.0.0 archive. + +The imported data are licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), +separately from the `atomref` code license. Cite both the +[atomref-proatoms concept DOI](https://doi.org/10.5281/zenodo.21291021) and the +[version-specific 2.0.0 DOI](https://doi.org/10.5281/zenodo.21291022). + ## Built-in quantity families - [Covalent radius](covalent_radius.md) - [van der Waals radius](van_der_waals_radius.md) - [Atomic radius](atomic_radius.md) - [X–H bond length](xh_bond_length.md) +- [Proatomic density](../guide/proatomic_density.md) diff --git a/docs/datasets/van_der_waals_radius.md b/docs/datasets/van_der_waals_radius.md index 3013d57..112626f 100644 --- a/docs/datasets/van_der_waals_radius.md +++ b/docs/datasets/van_der_waals_radius.md @@ -27,7 +27,7 @@ contacts. ## Alvarez van der Waals radii (`alvarez2013`) -This is the main van der Waals target set in the current release line. +This is the default built-in van der Waals target set. - **What it is:** a broad structural vdW set derived from statistical analysis of many interatomic distances in the Cambridge Structural Database. @@ -53,5 +53,5 @@ A compatibility-oriented table used historically in CSD tools. - **What it is:** an older practical vdW table with placeholder conventions. - **Coverage:** broad practical coverage, but not a modern scientific target set. -- **How `atomref` uses it:** support-only data for legacy compatibility and - future migration work. +- **How `atomref` uses it:** support-only data for explicit compatibility with + legacy workflows; it is not the default scientific target set. diff --git a/docs/datasets/xh_bond_length.md b/docs/datasets/xh_bond_length.md index 28364c5..fd38fc8 100644 --- a/docs/datasets/xh_bond_length.md +++ b/docs/datasets/xh_bond_length.md @@ -1,7 +1,7 @@ # X–H bond length -The `xh_bond_length` quantity is a small provisional addition in the current -release line. +The `xh_bond_length` quantity intentionally covers a narrow set of reference +data. Its purpose is not to claim a complete literature survey of X–H bond lengths. Instead, it provides a stable, provenance-aware starting point for @@ -34,6 +34,6 @@ That means the package draws a sharp line between: ## Scope note -This is intentionally a small addendum rather than full X–H support. -Broader X–H datasets, richer policies, and more complete literature treatment -are planned for `0.2.x`. +This is intentionally a small feature rather than a complete literature model +of X–H bonding. The documented sparse target and explicit fitted policy do not +claim broader X–H coverage or environment-specific bond lengths. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 680b755..eabc971 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -1,16 +1,19 @@ # Architecture -Publicly, `atomref` is still radii-first, with a small provisional X–H helper. +Publicly, `atomref` provides scalar radii and X–H reference workflows together +with neutral radial proatomic-density evaluation and two explicit pairwise +neutral-proatom modes. Internally, the package is built around four layers: 1. **elements** — stable element metadata and symbol canonicalization, -2. **registry** — curated quantity and dataset metadata plus packaged data - loading, +2. **registry** — curated quantity and dataset metadata plus generic packaged + scalar/radial data loading, 3. **policy core** — generic value selection with overrides, transfers, fallbacks, blocked keys, and provenance, -4. **quantity wrappers** — convenience APIs such as `atomref.radii` and - `atomref.xh`. +4. **quantity features** — scalar convenience APIs such as `atomref.radii` and + `atomref.xh`, plus immutable radial profiles and pairwise analysis in + `atomref.proatoms`. ## Core terminology @@ -18,7 +21,7 @@ A few terms are deliberately separated in the design: - **quantity** — the operational property family being requested, - **domain** — the key space used to index that quantity, -- **dataset** — one curated source table inside the quantity, +- **dataset** — one curated source payload inside the quantity, - **policy** — the ordered rule set used to select a final value. This separation is what allows the package to say, for example, that @@ -32,11 +35,20 @@ implements only one domain: - `element` -That means: +Packaged element data currently use two explicit storage kinds: -- packaged built-in sets are currently element-indexed scalar tables, -- `ValuePolicy` resolves element symbols, -- transfer fitting is performed over element-wise overlap. +- `element_scalar_csv` for dense-by-Z scalar tables, +- `element_radial_csv_zip` for a single-member ZIP containing shared-grid + radial profiles. + +`get_builtin_set()` dispatches both kinds and returns the `BuiltinSet` union of +`ElementScalarSet` and `ElementRadialSet`. Scalar consumers narrow that union +through `resolve_scalar_dataset_like()`. + +The policy and transfer machinery remains intentionally scalar-only: +`ValuePolicy` resolves element scalars and transfer fitting uses element-wise +scalar overlap. Radial profiles receive no `ValuePolicy`, substitution, or +linear-transfer behavior. The metadata keeps `domain` explicit now so later versions can extend the data model without having to reinterpret existing registry entries. @@ -106,4 +118,23 @@ placeholder status itself. Instead, its provenance is carried by The package deliberately avoids a large object graph or a chemistry-specific DSL. A quantity wrapper is usually only a thin adapter over the generic policy core. That keeps the internals easy to test and lets other scientific packages reuse -`atomref` without bringing in the rest of the Delone Commons stack. +`atomref` without requiring a larger application stack. + +## Documentation and distribution boundary + +The five files under `docs/notebooks/` are both the maintained Jupyter sources +and their documentation pages. `mkdocs-jupyter` renders their committed state +with execution disabled; `tools/check_notebooks.py` exercises temporary copies +one at a time through isolated, time-bounded standard Jupyter processes and +discards the results. Startup and cell timeouts remain separate, and a stalled +kernel cleanup or process exit is force-contained before the checker fails. +There is no exporter, generated notebook Markdown, source-copy step, or +output-freshness contract. + +The wheel remains a focused runtime artifact containing package code, typing +metadata, legal notices, and curated data. The source distribution additionally +contains tests, tools, durable documentation, and the single notebook sources. +Base, `notebooks`, and `all` installations are validated from the built wheel +in separate temporary environments during release preparation. The `all` +variant is checked as the exact union of every declared optional dependency +group rather than as an alias for one feature group. diff --git a/docs/dev/data_curation.md b/docs/dev/data_curation.md index 689ae24..c3c3979 100644 --- a/docs/dev/data_curation.md +++ b/docs/dev/data_curation.md @@ -1,26 +1,63 @@ # Data curation -Packaged tables are stored as CSV files indexed by atomic number. Dataset -metadata and provenance live in `src/atomref/data/registry.json`. +Packaged scalar tables are CSV files indexed by atomic number. The neutral +radial dataset is a shared-grid CSV payload inside a deterministic single-file +ZIP. Dataset metadata, storage declarations, provenance, aliases, coverage, and +attribution live in `src/atomref/data/registry.json`. -Placeholder values are modeled as dataset metadata, not as hard-coded Python -constants. +Generated scientific data must not be hand-edited. -The registry distinguishes several orthogonal concerns: +## Classification + +The registry keeps several concerns separate: - `quantity` — the operational lookup target, such as `covalent_radius` or - `van_der_waals_radius` -- `semantic_class` — what the dataset scientifically represents -- `usage_role` — whether the dataset is intended as a direct target set or as - support data for transfer -- `phase_context` — the physical context of the underlying values + `proatomic_density`; +- `semantic_class` — what the dataset scientifically represents; +- `origin_class` — how its values were obtained; +- `usage_role` — whether it is a direct target or transfer support; +- `phase_context` — the physical context of the values; +- `storage.kind` — the validated packaged payload shape. + +This is why `atomic_radius:rahm2016` remains an isolated-atom isodensity dataset +even when a radii policy uses it as support for a fitted van der Waals value. + +## Neutral proatomic snapshot -This matters for support-only datasets such as `atomic_radius:rahm2016`, which -is packaged as atomic support data and then used by the default van der Waals -policy through linear transfer. +`proatomic_density:pbe0_sfx2c_dyallv4z_h-lr_neutral_v2` is a deterministic +packaged snapshot of the `atomref-proatoms` 2.0.0 dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`. It selects the neutral H–Lr profiles, +retains the source grid through the first point above 20 bohr so the public +endpoint remains bracketed, and does not interpolate or alter stored density +values during curation. -To check that metadata and packaged tables stay synchronized, run: +The registry records the upstream release and dataset identity, PBE0/spherical +fractional-occupation UKS/sf-X2C/dyall-v4z method summary, source and basis +hashes, public domain and interpolation contract, CC BY 4.0 data license, and +the concept and version-specific Zenodo DOIs. Package code remains under its +own LGPL license. + +The maintainer-only builder reads a separately obtained immutable upstream +source tree; `atomref-proatoms` is not a runtime or build dependency: + +```bash +python tools/build_proatomic_density_snapshot.py \ + --source-dir ../atomref-proatoms-reference-v2.0.0/upstream/data/profiles/pbe0_sfx2c_dyallv4z_h-lr_spherical_v2 \ + --check +``` + +Write mode is reserved for deliberate snapshot regeneration after a separately +reviewed data change. It is not part of ordinary documentation or release +builds. + +## Validation + +Run the registry validator after any metadata or payload change: ```bash python tools/check_registry.py ``` + +It cross-checks metadata against every scalar and radial payload. Distribution +validation independently checks required members, attribution markers, and the +exact outer ZIP and inner CSV fingerprints in both source and wheel artifacts. diff --git a/docs/dev/dev_plan.md b/docs/dev/dev_plan.md deleted file mode 100644 index 94cdaac..0000000 --- a/docs/dev/dev_plan.md +++ /dev/null @@ -1,33 +0,0 @@ -# Development plan - -## Current status (implemented in the `0.1.x` line) - -- stable element metadata -- curated covalent, van der Waals, and atomic-radius support datasets -- explicit provenance and coverage metadata -- generic value-policy core plus radii and X–H convenience wrappers -- substitution and linear transfer -- custom element-indexed scalar sets -- policy-backed transfer sources -- nested-policy safeguards, transfer-depth tracking, and cycle detection -- provisional X–H support via `csd_legacy_xh_cno`, `XHPolicy`, and - `DEFAULT_XH_POLICY` - -## Planned for `0.2.x` - -- broader X–H datasets and policies -- experimental plus computational support sets -- pairwise helper logic such as reference sums and normalization schemes -- restoration of incomplete experimental data from broader-support predictors - -## Longer-term design ideas - -- radial atomic reference functions -- simple proto-density support based on spherically averaged atomic data - -## Possible future directions - -- more radii sets -- uncertainty and confidence flags -- ion-specific or atom-type-specific domains -- density-derived radii and related reference transforms diff --git a/docs/dev/ias_method_selection.md b/docs/dev/ias_method_selection.md new file mode 100644 index 0000000..a854859 --- /dev/null +++ b/docs/dev/ias_method_selection.md @@ -0,0 +1,122 @@ +# Pairwise boundary and IAS-proxy method selection + +An exhaustive numerical study considered returning every local minimum of the +neutral-promolecular line density between two atoms. Although this contract is +possible, it makes interpolation-scale features, one-ULP knot behavior, sub-ULP +event ordering, and exact tie handling part of the public science. + +That is more precision than the intended geometry workflow needs. `atomref` +therefore exposes two explicit methods instead of hiding one policy behind a +single number. + +## Recommended default: proatomic boundary + +The default method returns a stable neutral-proatom divider: + +- identical atoms use the exact midpoint; +- overlapping unlike atoms use the point where the two neutral proatomic + densities are equal; +- separated low-density contours use the midpoint of the contour gap; +- complete one-atom dominance is reported explicitly. + +The fixed per-atom tail cutoff is +`1e-4 electron/bohr^3`. It is a model policy for ignoring weak neutral-proatom +tails, not a universal QTAIM interaction threshold. + +![Neutral-proatom cutoff radii across H–Lr](../assets/ias-method-study/cutoff-radii.png) + +## Optional: practical promolecular minimum + +The optional minimum method is retained for Bader-oriented comparison and +calibration. It searches the sum of the two proatomic densities only where both +components remain above the cutoff. + +The search has a declared spatial resolution of `0.01 bohr`. It returns one +resolved minimum and, when useful, one competitive alternative. It does not +attempt to preserve every mathematical microminimum. Raw refined candidates +from the required `0.02` and `0.01 bohr` passes, and from the `0.005 bohr` +fallback when used, are combined before one resolution-coalescing step. +Position-sorted candidates connected by successive gaps below `0.01 bohr` +represent one resolved valley. Distinct adjacent binary64 grid coordinates are +retained so both endpoints and the midpoint remain available near cutoff +contact. Refined cutoff endpoints and nuclei are discarded rather than clamped. + +For identical atoms, symmetry still fixes the returned coordinate at `R/2`. +The Li–Li example shows why this rule is necessary: the raw interpolant has +slightly deeper symmetric off-centre valleys, but either one alone is a poor +separator for identical atoms. + +![Li–Li raw valleys and symmetry midpoint](../assets/ias-method-study/li-li-symmetry.png) + +The two methods are scientifically different. For C–O at 1.5 bohr, the +balanced-contribution coordinate and the promolecular minimum do not coincide. +That disagreement is expected rather than a solver defect. + +![C–O method comparison](../assets/ias-method-study/c-o-method-comparison.png) + +## Numerical evidence + +The executed study compared the practical minimum search with a slower +`0.001 bohr` reference over 300 deterministic H–Lr cases and seven adversarial +cases selected from the exhaustive all-minima analysis. + +- All 298 cases where both methods returned a minimum agreed within + `0.01 bohr`. +- The largest coordinate difference was about `0.00129 bohr`. +- Two extremely short-distance reference minima were intentionally reported as + unresolved because they were narrower than the public practical resolution + and lay immediately beside a nucleus. +- On the machine used for the study, the standalone implementation took roughly + `0.05–0.06 ms` per cached boundary estimate and about `0.8 ms` per cached + practical minimum estimate. + +These figures are evidence, not portable performance guarantees. + +## Why exhaustive all-minima search is not the default + +The exhaustive investigation found real edge cases: + +- a minimum only 27 binary64 coordinates from a profile knot; +- one-ULP inversions at exact knots; +- multiple shallow or symmetric minima, including five for Li–Li at 5 bohr; +- fourteen H–U minima in a case where a boundary value was lower; +- distinct exact events that rounded to one float; +- value errors large enough to complicate exact global-tie classification. + +Those facts matter under an “expose every local minimum” contract. They do not +materially improve a stable pairwise divider, and the practical minimum mode +coalesces or rejects structure below its declared resolution. + +## Public API + +```python +estimate_proatomic_boundary(...) +estimate_promolecular_density_minimum(...) +estimate_ias_position(..., mode="boundary" | "minimum") +``` + +`boundary` is the default. The dispatcher never silently switches modes. +Neither result is an exact molecular-density QTAIM surface. + +## Scientific context + +The interpretation follows three established ideas while keeping their scopes +separate: + +- QTAIM basin boundaries are zero-flux surfaces of the molecular electron + density: Bader, *Chemical Reviews* **91** (1991), 893–928, + [doi:10.1021/cr00005a013](https://doi.org/10.1021/cr00005a013). +- Hirshfeld stockholder weights are proportional to free-atom reference + densities, which motivates equal pairwise contributions as a stable divider: + Hirshfeld, *Theoretica Chimica Acta* **44** (1977), 129–138, + [doi:10.1007/BF00549096](https://doi.org/10.1007/BF00549096). +- Radial-density atom and bond constructions are useful model definitions but + require comparison with molecular-density results: Warburton, Poirier, and + Nippard, *J. Phys. Chem. A* **115** (2011), 852–867, + [doi:10.1021/jp1093417](https://doi.org/10.1021/jp1093417). + +The complete calculations, representative profiles, adversarial cases, and +local timing record are in the directly rendered +[`04-ias-method-selection-study.ipynb`](../notebooks/04-ias-method-selection-study.ipynb) +notebook. Its committed outputs are shown without re-execution during the +documentation build. diff --git a/docs/guide/install.md b/docs/guide/install.md index e7e0697..c5aa125 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -1,30 +1,75 @@ # Install -For normal use, install the runtime package: +## Lightweight runtime + +Install the dependency-free runtime for radii, X–H, density, registry, policy, +and pairwise APIs: ```bash pip install atomref ``` -`atomref` is pure Python and has no required runtime dependencies outside the -standard library. +`atomref` requires Python 3.10 or later. The `0.2.1` CI matrix covers Python +3.10 through 3.14. Its required runtime dependency set is empty; every core +calculation uses the Python standard library and packaged data. + +Verify the installation with a useful result: + +```bash +python -c "import atomref as ar; print(ar.get_covalent_radius('C'))" +``` + +## Notebook tooling + +Install the direct notebook renderer, standard Jupyter execution support, +kernel, and plotting dependency with: + +```bash +pip install "atomref[notebooks]" +``` + +The plural `notebooks` name describes the shipped notebook collection and does +not imply installation of the Jupyter Notebook server application. This extra +is needed to execute or render the committed `.ipynb` examples locally. It is +not needed to use any `atomref` runtime API or to read the rendered notebooks on +the documentation site. + +## Complete optional environment -For local development, documentation work, and tests, install the editable -package together with the main extras: +Install every optional dependency declared by the project with: ```bash -pip install -e ".[test,docs,dev]" +pip install "atomref[all]" ``` -Those extras currently cover: +In `0.2.1`, `all` is the exact deduplicated union of `test`, `notebooks`, +`docs`, and `dev`. It therefore includes notebook tooling, documentation +tooling, pytest, linting, build, and distribution-validation dependencies. The +base package remains dependency-free. -- `test` — pytest and test-only compatibility helpers, -- `docs` — MkDocs and API documentation tooling, -- `dev` — flake8, build, and release metadata checks. +## Contributor environment +For a complete editable repository environment, use: -For a full local pre-release validation pass after installing those extras, run: +```bash +pip install -e ".[all]" +``` + +Install a narrower group when only one task is needed: + +- `test` provides pytest and the Python 3.10 TOML compatibility dependency. +- `notebooks` provides notebook rendering and execution, a Python kernel, and + plotting. +- `docs` provides MkDocs, Material, and typed API-documentation tooling. +- `dev` provides lint, build, and distribution-metadata checks. + +Run the full local release validation with: ```bash python tools/release_check.py ``` + +That command requires a clean worktree, builds fresh artifacts from a temporary +extraction of the committed `HEAD` with conventional file modes, and validates +clean base, `notebooks`, and `all` installations in temporary virtual +environments. diff --git a/docs/guide/non_goals.md b/docs/guide/non_goals.md index b38aa68..4d46104 100644 --- a/docs/guide/non_goals.md +++ b/docs/guide/non_goals.md @@ -13,11 +13,15 @@ It is **not** trying to be: - an environment-specific chemistry model, - a machine-learning framework for extrapolating unseen chemistry. -The package is about **curated reference data and explicit lookup policies**. -That includes provenance, transfer from broader support datasets, and stable API -surfaces that higher-level scientific code can rely on. +The package is about **curated atomic references and explicit selection or +model semantics**. That includes provenance-aware scalar lookup, transfer from +broader support datasets, frozen neutral spherical proatomic profiles, and the +two documented pairwise reference-atom modes. -Future versions may widen the range of supported *reference-data families* — for -example X–H distances or radial atomic reference functions — but the package -should still remain a small reference-data layer rather than a full chemistry -platform. +The pairwise helpers are not molecular electron-density calculations or exact +QTAIM surfaces. Radial data are not completed through scalar policy, +correlation, or neighboring-element substitution. + +Future versions may widen the range of supported *reference-data families*, but +the package should still remain a small reference-data layer rather than a full +chemistry platform. diff --git a/docs/guide/notebooks.md b/docs/guide/notebooks.md index 2ad0045..7c2cb87 100644 --- a/docs/guide/notebooks.md +++ b/docs/guide/notebooks.md @@ -1,25 +1,59 @@ # Notebook gallery -`atomref` ships example Jupyter notebooks that cover the main workflows. -Each notebook is available in two forms: +`atomref` ships five explanatory Jupyter notebooks. These are the actual +committed `.ipynb` sources rendered directly by MkDocs—there is no generated +Markdown copy or parallel notebook tree. -- the original `.ipynb` file in the repository, -- a rendered Markdown copy included in these docs. +Documentation builds show the committed Markdown, code, mathematics, saved +text, and saved PNG plots. They do not execute or rewrite the notebooks. A +separate release check runs each temporary copy in its own bounded standard +Jupyter process, fails on execution exceptions or lifecycle timeouts, and +discards the temporary outputs without comparing them with the committed +files. Kernel startup and cell execution have separate timeouts; a stalled +cleanup or process exit cannot block the remaining release gate indefinitely. -That way users can either run the notebooks locally or read them directly on the -documentation site. +## User workflows -## Available notebooks +- [Quickstart](../notebooks/01-quickstart.ipynb) introduces direct scalar + values, provenance-carrying lookup, and dataset discovery. +- [Policies and assessment](../notebooks/02-policies-and-assessment.ipynb) + explains ordered restoration, fitted transfers, provenance, and policy + summaries. +- [Custom sets and discovery](../notebooks/03-custom-sets-and-discovery.ipynb) + builds a user-provided scalar set and inspects the packaged catalog. +- [Proatomic density and IAS workflows](../notebooks/05-proatomic-density-and-ias.ipynb) + covers profile provenance, unit-aware evaluation, both pairwise modes, + diagnostic cases, formulas, and saved plots. -- [Quickstart notebook](../notebooks/01-quickstart.md) — basic imports, - `get_*` vs `lookup_*`, quantity discovery, and packaged-set access. -- [Policies and assessment notebook](../notebooks/02-policies-and-assessment.md) - — overrides, transfer-backed policies, and policy summaries. -- [Custom sets and discovery notebook](../notebooks/03-custom-sets-and-discovery.md) - — user-defined sets, catalog inspection, and metadata exploration. +## Supporting numerical study -The original notebook files are also in the repository: +- [IAS method-selection study](../notebooks/04-ias-method-selection-study.ipynb) + records the numerical evidence behind the stable boundary and practical + minimum contracts. It is supporting information, not an alternative runtime + implementation. -- [`01-quickstart.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/notebooks/01-quickstart.ipynb) -- [`02-policies-and-assessment.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/notebooks/02-policies-and-assessment.ipynb) -- [`03-custom-sets-and-discovery.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/notebooks/03-custom-sets-and-discovery.ipynb) +The durable scientific summary is also available in +[Pairwise boundary and IAS-proxy method selection](../dev/ias_method_selection.md). + +## Notebook sources + +The canonical file for every rendered page is available here for viewing or +download: + +- [`01-quickstart.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/docs/notebooks/01-quickstart.ipynb) +- [`02-policies-and-assessment.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/docs/notebooks/02-policies-and-assessment.ipynb) +- [`03-custom-sets-and-discovery.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/docs/notebooks/03-custom-sets-and-discovery.ipynb) +- [`04-ias-method-selection-study.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/docs/notebooks/04-ias-method-selection-study.ipynb) +- [`05-proatomic-density-and-ias.ipynb`](https://github.com/DeloneCommons/atomref/blob/main/docs/notebooks/05-proatomic-density-and-ias.ipynb) + +## Run notebooks locally + +Install all direct notebook dependencies with: + +```bash +pip install "atomref[notebooks]" +``` + +The notebooks are designed for a standard Python Jupyter kernel. Saved outputs +are examples, not a byte-for-byte reproducibility promise: timing, rendering +metadata, and PNG bytes may vary when a notebook is deliberately rerun. diff --git a/docs/guide/policies.md b/docs/guide/policies.md index b9e3b7a..2586825 100644 --- a/docs/guide/policies.md +++ b/docs/guide/policies.md @@ -14,7 +14,7 @@ A few terms appear repeatedly in the API and docs: - **quantity** — the operational property family being requested. - **domain** — the lookup key space. In the current runtime that means `element`, so lookups are keyed by element symbol. -- **dataset** — a curated named table inside one quantity. +- **dataset** — a curated named source payload inside one quantity. - **policy** — the ordered rule set used to resolve missing values. The quantity and dataset live in the curated registry. The policy is the diff --git a/docs/guide/proatomic_density.md b/docs/guide/proatomic_density.md new file mode 100644 index 0000000..d6fccd8 --- /dev/null +++ b/docs/guide/proatomic_density.md @@ -0,0 +1,140 @@ +# Proatomic density + +`atomref` supplies frozen neutral spherical proatomic-density profiles for H +through Lr. They come from the `atomref-proatoms` 2.0.0 dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`: PBE0, self-consistent spherical +fractional-occupation UKS, spin-free one-electron X2C, and the dyall-v4z basis. +The exact dataset metadata, source hashes, CC BY 4.0 attribution, and DOIs are +available through `get_proatomic_density_set_info()`. + +Evaluate one scalar coordinate at a time: + +```python +import atomref as ar + +rho = ar.get_proatomic_density( + "O", + 0.75, + radius_unit="angstrom", + density_unit="electron/bohr^3", +) + +profile = ar.get_proatomic_density_profile("O") +rho_at_1_5_bohr = profile(1.5, radius_unit="bohr") +``` + +Radius and density units are independent. Radius coordinates accept `angstrom` +(the default) or `bohr`; density output accepts `electron/bohr^3` (the default) +or `electron/angstrom^3`. + +The supported public interval is exactly 0 through 20 bohr, inclusive. The +stored snapshot retains one source point above 20 bohr only to bracket the +endpoint. Between positive stored knots, evaluation uses the dependency-free +`loglog_positive_bracketed_v1` contract: linear interpolation in `log(r)` and +`log(rho)`. Exact knots return their stored values. From `r = 0` through the +first positive stored radius, the first stored density is returned; this is a +finite-grid convention, not an exact evaluation at the nucleus. Negative, +non-finite, and above-domain radii raise `ValueError`; there is no extrapolation +or zero fill. + +Elements may be supplied as symbols or integer atomic numbers. Symbols follow +the package's normal element handling, and `D` and `T` use H's electronic +profile. Invalid values and elements beyond Lr return `None`; no neighboring- +element substitution, correlation, ionic selection, or scalar `ValuePolicy` is +applied. + +The ZIP snapshot loads lazily through `get_builtin_set()`. Loaded sets, shared +grids, stored values, and cached profile views are immutable. This API describes +method-, basis-, state-, and sphericalization-defined isolated-atom references, +not unique basis-independent atomic observables or molecular densities. + +The metadata names the immutable source as `atomref-proatoms` 2.0.0, dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`, and records the source profile, +metadata, and basis hashes. The imported profile data are CC BY 4.0; package +code has its own license. Use `get_proatomic_density_set_info()` to retain this +provenance in downstream work. + +## Pairwise estimates + +Three functions expose the pairwise IAS estimators: + +```python +boundary = ar.estimate_proatomic_boundary("C", "O", 1.5, distance_unit="bohr") +minimum = ar.estimate_promolecular_density_minimum( + "C", "O", 1.5, distance_unit="bohr" +) +same_boundary = ar.estimate_ias_position( + "C", "O", 1.5, mode="boundary", distance_unit="bohr" +) +``` + +Pair distances must satisfy `0 < R <= 20 bohr`; the default input unit is +angstrom. Coordinates are measured from the first atom toward the second and +are returned in the selected distance unit. Distance and density units are +independent, just as they are for profile evaluation. + +Reversing atom A and atom B maps every returned primary or alternative +coordinate `x` to `R - x`, swaps A/B component fields, and relabels +`dominant_atom` and its role. Method, status, total density, cutoff regime, and +orientation-independent search diagnostics remain equivalent under that +relabeling. + +`boundary` is the dispatcher default and the recommended stable geometry-facing +choice. Identical atoms return exactly `R/2`. While unlike-atom cutoff contours +overlap, it returns the equal-neutral-proatom-density divider. After the +contours separate, it returns the midpoint of their low-density gap. Complete +one-atom dominance is reported without inventing a coordinate. + +`minimum` is an optional, Bader-oriented neutral-promolecular proxy. It searches +for one practically resolved minimum of the summed line density only inside the +meaningful-overlap interval. That interval uses a fixed per-atom cutoff of +exactly `1e-4 electron/bohr^3`; the cutoff is a model tail policy, not a +universal interaction threshold. The declared minimum resolution is +`0.01 bohr`. The mode deliberately coalesces or rejects sub-resolution +features, may expose one competitive alternative, and never silently switches +to boundary mode. A candidate at a cutoff endpoint or nucleus is not a valid +strict-interior minimum. + +## Results and diagnostic statuses + +Both functions return an immutable `IASPositionResult` containing the method, +status, units, component densities, cutoff geometry, search diagnostics, and +dataset/interpolation/numerical-contract provenance. Valid pairs for which a +mode is not scientifically applicable return a typed result with no coordinate; +missing profiles return `None`. + +Always inspect `method`, `status`, and `position_from_a`. The explicit statuses +are: + +| Status | Meaning | +|---|---| +| `ok` | The requested mode returned its ordinary result. | +| `low_density_gap` | The fixed cutoff contours are separated; boundary mode may return the gap midpoint, while minimum mode returns no coordinate. | +| `one_atom_dominates` | No interior equal-contribution boundary exists for the unlike pair. | +| `no_resolved_interior_minimum` | Minimum mode found no strict-interior valley at its practical resolution. | +| `boundary_dominated` | A selected interior minimum exists, but an internuclear-interval boundary is lower. | +| `ambiguous_competing_minima` | A competitive resolved alternative is reported. | +| `search_unstable` | Required search passes did not agree at the declared resolution. | + +`ambiguous`, `search_converged`, `search_passes`, `dominant_atom`, and the +alternative-position fields preserve the corresponding diagnostics. The result +also records the exact dataset ID, interpolation contract, cutoff density, and +pairwise numerical contract. + +## Choosing a mode and understanding the limit + +Use `boundary` for a stable pairwise divider in geometry, Voronoi/Laguerre +calibration, and similar reference-atom workflows. Use `minimum` only when a +cutoff-bounded promolecular line-density valley is the intended quantity and a +resolution-limited non-result is acceptable. + +These are neutral-proatom estimates. Neither mode locates a molecular QTAIM +zero-flux surface or an exact molecular-density critical point. They do not add +ionic, environment-dependent, vectorized, molecular-density, or grid-density +behavior. See the directly rendered +[proatomic density and IAS notebook](../notebooks/05-proatomic-density-and-ias.ipynb) +for the public workflows, the +[IAS method-selection notebook](../notebooks/04-ias-method-selection-study.ipynb) +for the executed supporting analysis, and the +[durable method summary](../dev/ias_method_selection.md) for the numerical +decision and documented limitations of exhaustive all-minima enumeration. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 72e6858..83ea590 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -1,9 +1,12 @@ # Quickstart -The two most important user-facing ideas in `atomref` are: +Install the lightweight runtime: -- `get_*` returns only the selected number, -- `lookup_*` returns the number **and** provenance metadata. +```bash +pip install atomref +``` + +Then request the reference values needed by a structure workflow: ```pycon >>> import atomref as ar @@ -13,49 +16,98 @@ The two most important user-facing ideas in `atomref` are: 1.5 >>> ar.get_xh_bond_length("N") 1.015 +``` + +Packaged radii and X–H lengths are in angstrom. `get_*` is the concise path +when an algorithm only needs the selected number. + +## Keep provenance with the result + +Use the matching `lookup_*` helper when a result must record how it was chosen: + +```pycon >>> lookup = ar.lookup_vdw_radius("Pm") >>> lookup.value 2.8972265395148358 >>> lookup.source 'transfer_linear' +>>> lookup.transfer_depth +1 >>> lookup.resolved_from (DatasetRef(quantity='atomic_radius', set_id='rahm2016'),) ``` -Use `get_*` when you only need the value. Use `lookup_*` when you want to know -whether the result came from the preferred dataset, a support dataset, a policy -override, or a fallback. +A `LookupResult` distinguishes direct, overridden, substituted, fitted, +fallback, placeholder, and missing values. See the [policy guide](policies.md) +when you need to configure that resolution process. + +## Evaluate a neutral proatomic density -You can inspect the packaged quantity layer directly: +The density API accepts an element symbol or atomic number and evaluates one +scalar radius at a time: ```pycon ->>> import atomref as ar ->>> ar.list_quantities() -('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length') ->>> ar.get_quantity_info("xh_bond_length") -QuantityInfo(quantity='xh_bond_length', domain='element', units='angstrom', description='Element-indexed reference X-H bond lengths keyed by parent element X and intended for hydrogen-position normalisation or related geometry workflows.') ->>> [info.ref.set_id for info in ar.list_radii_set_infos("van_der_waals", usage_role="target")] -['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] +>>> rho = ar.get_proatomic_density( +... "O", +... 0.75, +... radius_unit="angstrom", +... density_unit="electron/bohr^3", +... ) +>>> rho +0.1141799693379811 ``` -And you can load a packaged set object directly: +The packaged neutral H–Lr profiles have a strict 0–20 bohr public radius domain. +Radius and density units are selected independently. Invalid or out-of-range +radii raise `ValueError`; unsupported elements return `None`. + +## Choose a pairwise reference-atom mode + +For a C–O pair at 1.43 Å: ```pycon ->>> import atomref as ar +>>> boundary = ar.estimate_proatomic_boundary("C", "O", 1.43) +>>> boundary.method, boundary.status +('equal_proatom_density', 'ok') +>>> minimum = ar.estimate_promolecular_density_minimum("C", "O", 1.43) +>>> minimum.method, minimum.status +('promolecular_density_minimum', 'ok') +>>> ar.estimate_ias_position("C", "O", 1.43) == boundary +True +``` + +`boundary` is the stable default: it uses homonuclear symmetry, equal neutral- +proatom contributions in the meaningful-overlap region, and a fixed contour- +gap rule at long separation. `minimum` is an optional, cutoff-bounded, +`0.01 bohr`-resolution proxy for a minimum of the summed promolecular line +density. It may return an explicit non-result and never silently switches to +boundary mode. + +Neither result is an exact molecular-density QTAIM surface. Read the +[scientific guide](proatomic_density.md) before using pairwise coordinates in a +scientific interpretation. + +## Inspect or load an exact dataset + +The registry lets an application report and select exact sources: + +```pycon +>>> ar.list_quantities() +('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length', 'proatomic_density') +>>> [info.ref.set_id for info in ar.list_radii_set_infos( +... "van_der_waals", usage_role="target" +... )] +['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] >>> vdw = ar.get_radii_set("van_der_waals", "alvarez2013") >>> vdw.get("O") 1.5 ->>> raw = ar.get_builtin_set(ar.DatasetRef("atomic_radius", "rahm2016")) ->>> raw.get("Pm") -2.83 ->>> xh = ar.get_xh_set("csd_legacy_xh_cno") ->>> xh.get("C") -1.089 ``` -For longer, runnable examples see: +## Continue with executable examples -- the [notebook overview](notebooks.md), -- the [quickstart notebook page](../notebooks/01-quickstart.md), -- the [policies notebook page](../notebooks/02-policies-and-assessment.md), -- the [custom sets notebook page](../notebooks/03-custom-sets-and-discovery.md). +- [Notebook overview](notebooks.md) +- [Quickstart notebook](../notebooks/01-quickstart.ipynb) +- [Policies and assessment notebook](../notebooks/02-policies-and-assessment.ipynb) +- [Custom sets and discovery notebook](../notebooks/03-custom-sets-and-discovery.ipynb) +- [IAS method-selection study](../notebooks/04-ias-method-selection-study.ipynb) +- [Proatomic density and IAS workflows](../notebooks/05-proatomic-density-and-ias.ipynb) diff --git a/docs/index.md b/docs/index.md index 198fa6a..075cd2a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,156 +6,212 @@ [![Python Versions](https://img.shields.io/pypi/pyversions/atomref.svg)](https://pypi.org/project/atomref/) [![License](https://img.shields.io/pypi/l/atomref.svg)](https://github.com/DeloneCommons/atomref/blob/main/LICENSE) -`atomref` is a small pure-Python package for **curated atomic reference data** -and **provenance-aware lookup policies** used by geometry and -structure-analysis algorithms. - -It is not meant to be yet another periodic-table encyclopedia. The package is -for code that needs stable atomic reference values with explicit provenance, -clear fallback behavior, and honest handling of incomplete preferred datasets. - -What you get in the current release line: - -- stable element metadata, -- curated named radii sets, -- provisional X–H bond-length support for hydrogen-normalisation workflows, -- dataset provenance and coverage metadata, -- deterministic lookup policies, -- substitution and linear transfer from support datasets or policies into target datasets, -- guarded nested policy-backed transfers with explicit transfer depth, - conservative fit/prediction controls, and cycle detection, -- user-defined custom element-indexed scalar sets. - -## Core terms - -`atomref` uses a small vocabulary on purpose. - -- **quantity** — the operational property family being requested, such as - `covalent_radius`, `van_der_waals_radius`, `atomic_radius`, or - `xh_bond_length`. -- **domain** — the key space used to index that quantity. In the current - runtime, the supported domain is `element`, meaning lookups are keyed by an - element symbol. -- **dataset** — one curated named table inside a quantity, such as - `cordero2008`, `alvarez2013`, or `csd_legacy_xh_cno`. -- **policy** — the ordered rule set that decides what value to return when the - preferred dataset is incomplete. - -The metadata layer already records `domain` explicitly because the package is -built for later extension, but the current runtime intentionally keeps the -implementation narrow and stable: **the current runtime resolves only -element-domain scalar values**. - -## Why this exists - -Scientific software often wants a complete lookup table, but the best dataset -for the job is rarely complete. `atomref` makes that situation explicit. -Instead of hiding ad hoc defaults inside algorithm code, you choose a target -set, describe how missing values may be restored, and keep provenance on what -was actually returned. - -The built-in default behavior is intentionally simple and practical: - -- **Cordero covalent radii** (`cordero2008`) are the preferred covalent target - set, with missing values substituted from the **legacy CSD covalent radii** - (`csd_legacy_cov`). -- **Alvarez van der Waals radii** (`alvarez2013`) are the preferred vdW target - set, with missing values restored from the **Rahm isodensity atomic radii** - (`rahm2016`) through a fitted linear transfer. -- **CSD/ConQuest hydrogen-normalisation defaults** (`csd_legacy_xh_cno`) are a - provisional sparse X–H target set for `C`, `N`, and `O`, with other parent - elements inferred from **Cordero covalent radii** through a fitted linear - transfer. - -Nested policy predictors are supported too. `LinearTransfer` separates -**fit-time** use of nested predictor values from **prediction-time** use. By default, the fit may use only direct nested -values, while the final requested element may still use one additional -nested completion step. That is a useful compromise for workflows such as -provisional X–H inference from a chosen covalent-radii policy. - -## Quick example +`atomref` provides cited atomic properties and frozen spherical free-atom +electron densities through a small Python API for crystallographic, +quantum-chemical, and molecular-structure algorithms. + +Use it when your software needs dependable atomic radii, X–H reference lengths, +neutral proatomic densities, or pairwise reference-atom boundaries without +embedding another project-local table and its fallback rules. The core runtime +is pure Python and has **no required third-party dependencies**. + +## Install and get a useful result + +```bash +pip install atomref +``` + +```python +import atomref as ar + +covalent_c = ar.get_covalent_radius("C") +xh_n = ar.get_xh_bond_length("N") +rho_o = ar.get_proatomic_density("O", 0.75) +boundary = ar.estimate_proatomic_boundary("C", "O", 1.43) +minimum = ar.estimate_promolecular_density_minimum("C", "O", 1.43) +``` + +The scalar results above are in documented units: radii and X–H lengths use +angstrom, while the density call returns electron/bohr³ by default. Pairwise +coordinates are measured from the first atom toward the second. For this C–O +example, `boundary` is the stable equal-proatom divider and `minimum` is the +optional, resolution-limited minimum of the summed promolecular line density. + +[Start with the quickstart](https://delonecommons.github.io/atomref/guide/quickstart/) +or open the [complete API reference](https://delonecommons.github.io/atomref/api/). + +## What it solves + +- **Bond and contact geometry:** select named covalent and van der Waals radii + instead of scattering constants through structure code. +- **Hydrogen normalization:** obtain provisional, provenance-aware X–H target + lengths keyed by the parent element. +- **Incomplete reference tables:** use explicit substitution or fitted transfer + policies and inspect how a value was obtained. +- **Free-atom density sampling:** evaluate immutable neutral H–Lr spherical + profiles with explicit coordinate and density units over a strict 0–20 bohr + domain. +- **Pairwise reference-atom models:** choose a stable proatomic boundary or an + explicitly cutoff-bounded promolecular-minimum proxy without presenting + either as an exact molecular QTAIM surface. + +Most convenience functions come in two forms: ```pycon >>> import atomref as ar ->>> ar.get_covalent_radius("C") -0.76 ->>> ar.get_vdw_radius("O") -1.5 ->>> ar.get_xh_bond_length("N") -1.015 ->>> lookup = ar.lookup_vdw_radius("Pm") ->>> lookup.value +>>> ar.get_vdw_radius("Pm") 2.8972265395148358 ->>> lookup.source -'transfer_linear' ->>> lookup.transfer_depth -1 ->>> lookup.resolved_from +>>> result = ar.lookup_vdw_radius("Pm") +>>> result.source, result.transfer_depth +('transfer_linear', 1) +>>> result.resolved_from (DatasetRef(quantity='atomic_radius', set_id='rahm2016'),) ``` -`get_*` returns only the number. `lookup_*` returns a `LookupResult` that also -records where the value came from, whether a transfer model or policy source was -involved, and how many transfer steps were needed (`transfer_depth`). +`get_*` returns the selected number. `lookup_*` returns a typed `LookupResult` +with the source, supporting datasets, placeholder state, fit information, and +transfer depth. -You can inspect the packaged quantity and dataset catalog directly: +## Why adopt `atomref`? -```pycon ->>> import atomref as ar ->>> ar.list_quantities() -('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length') ->>> ar.get_quantity_info("xh_bond_length") -QuantityInfo(quantity='xh_bond_length', domain='element', units='angstrom', description='Element-indexed reference X-H bond lengths keyed by parent element X and intended for hydrogen-position normalisation or related geometry workflows.') ->>> [info.ref.set_id for info in ar.list_dataset_infos("van_der_waals_radius", usage_role="target")] -['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] +A local constants file is easy to start and difficult to maintain. It usually +accumulates uncited values, silent replacements for missing elements, ambiguous +units, and behavior that cannot be reviewed independently of the consuming +algorithm. `atomref` keeps those concerns in one versioned layer: + +- every packaged dataset has a stable identifier, coverage metadata, and + bibliographic provenance; +- lookup rules are explicit and deterministic rather than hidden in callers; +- direct, substituted, fitted, fallback, placeholder, and missing results stay + distinguishable; +- public types, units, valid ranges, and failure behavior are documented; +- packaged data and attribution are checked in both wheel and source + distributions; +- the dependency-free runtime can be embedded in lightweight scientific tools. + +The package does not claim that one table or policy is universally correct. It +makes the selected reference and the assumptions around it visible. + +## Installation choices + +The base install is sufficient for every runtime API: + +```bash +pip install atomref ``` -You can also load a packaged set directly: +Install the `notebooks` extra to execute or render the shipped Jupyter examples +and their plots: -```pycon ->>> import atomref as ar ->>> vdw = ar.get_radii_set("van_der_waals", "alvarez2013") ->>> vdw.get("O") -1.5 ->>> xh = ar.get_xh_set("csd_legacy_xh_cno") ->>> xh.get("C") -1.089 +```bash +pip install "atomref[notebooks]" ``` -## Notebook walkthroughs +Install every optional dependency declared by the project with: + +```bash +pip install "atomref[all]" +``` -The repository ships example notebooks for the main workflows. In the -documentation they are also available as rendered Markdown pages, so users can -read them without opening Jupyter first. +`all` is the exact union of `test`, `notebooks`, `docs`, and `dev`, so it is +appropriate for a complete contributor or release environment. See the +[installation guide](https://delonecommons.github.io/atomref/guide/install/) +for the narrower groups. + +## Data, provenance, and scientific scope + +The scalar catalog includes named covalent, van der Waals, atomic-isodensity, +and provisional X–H datasets. Registry metadata separates the requested +quantity from scientific classification and from whether a dataset is a direct +target or transfer support. + +The neutral proatomic profiles are a deterministic packaged snapshot of the +`atomref-proatoms` 2.0.0 dataset +`pbe0_sfx2c_dyallv4z_h-lr_spherical_v2`. They cover H–Lr and record the PBE0, +self-consistent spherical fractional-occupation UKS, spin-free one-electron +X2C, and dyall-v4z definition, source hashes, CC BY 4.0 data license, and both +concept and version-specific DOIs. + +These profiles are isolated, neutral, spherical reference atoms—not molecular +electron densities. Density evaluation is scalar, and the public radius domain +is exactly 0–20 bohr. Pairwise `boundary` mode is the stable default. Optional +`minimum` mode searches only where both proatoms remain above +`1e-4 electron/bohr^3`, has a declared `0.01 bohr` resolution, and may return a +typed diagnostic without a coordinate. + +- [Dataset catalog and provenance](https://delonecommons.github.io/atomref/datasets/) +- [Proatomic-density and pairwise scientific guide](https://delonecommons.github.io/atomref/guide/proatomic_density/) +- [Policy guide](https://delonecommons.github.io/atomref/guide/policies/) +- [Explicit non-goals](https://delonecommons.github.io/atomref/guide/non_goals/) + +## Citation and licenses + +Cite `atomref` as software using the repository-level +[`CITATION.cff`](https://github.com/DeloneCommons/atomref/blob/main/CITATION.cff). +There is no preferred paper citation; the versioned software release is the +canonical object to cite. + +Except for separately identified material, the software and accompanying +repository content are licensed under LGPL-3.0-or-later. The bundled neutral +proatomic-density snapshot is licensed separately under CC BY 4.0. +[`NOTICE.md`](https://github.com/DeloneCommons/atomref/blob/main/NOTICE.md) +records the exact boundary, attribution, and source DOIs. The packaged registry +metadata records the exact source commit and SHA-256 hashes. + +## Executable notebook documentation + +The documentation renders the actual committed `.ipynb` files directly, +including Markdown, code, mathematics, saved text, and saved PNG plots. Site +builds do not execute or rewrite them; separate bounded Jupyter workers verify +temporary copies without retaining their results. - [Notebook overview](https://delonecommons.github.io/atomref/guide/notebooks/) - [Quickstart notebook](https://delonecommons.github.io/atomref/notebooks/01-quickstart/) - [Policies and assessment notebook](https://delonecommons.github.io/atomref/notebooks/02-policies-and-assessment/) - [Custom sets and discovery notebook](https://delonecommons.github.io/atomref/notebooks/03-custom-sets-and-discovery/) +- [IAS method-selection study](https://delonecommons.github.io/atomref/notebooks/04-ias-method-selection-study/) +- [Proatomic density and IAS workflows](https://delonecommons.github.io/atomref/notebooks/05-proatomic-density-and-ias/) + +## Dataset and policy discovery -## Relationship to Delone Commons +The lower-level registry is available when an application needs to choose or +report an exact source: + +```pycon +>>> import atomref as ar +>>> ar.list_quantities() +('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length', 'proatomic_density') +>>> [info.ref.set_id for info in ar.list_dataset_infos( +... "van_der_waals_radius", usage_role="target" +... )] +['bondi1964', 'rowland_taylor1996', 'alvarez2013', 'chernyshov2020'] +>>> profile_info = ar.get_proatomic_density_set_info() +>>> profile_info.ref.set_id +'pbe0_sfx2c_dyallv4z_h-lr_neutral_v2' +``` -`atomref` is designed as a standalone package, but within Delone Commons it is -primarily intended to support chemistry-aware packages such as: +Custom element-indexed scalar sets can participate in the same policy layer. +Radial profiles deliberately do not: no scalar `ValuePolicy`, neighboring- +element substitution, or fitted correlation is applied to density data. -- `molcryst`, for covalent-bond detection, contact analysis, and hydrogen workflows, -- future `chemvoro`, for chemistry-aware contact and hydrogen workflows. +## Use in scientific software -By contrast, `pyvoro2` and `pbcgraph` are intentionally general mathematical -packages and are not direct consumers of `atomref`. +`atomref` is a standalone package for physicochemical and structural-analysis +software that needs curated atomic properties, proatomic densities, or explicit +reference-data policy. Purely mathematical packages can remain independent of +those choices until a consuming application needs atomic context. -## Data curation and developer tools +## Maintainer checks -The repository also ships small maintenance tools. The most important ones are: +The repository keeps a small set of release tools: -- `python tools/check_registry.py` — validate curated registry metadata against - packaged CSV tables, -- `python tools/check_notebooks.py` — execute notebook code cells, -- `python tools/export_notebooks.py` — turn notebooks into Markdown pages for - the docs, -- `python tools/gen_readme.py` — regenerate `README.md` from this page, -- `python tools/release_check.py` — run the full release-preparation checklist, - including linting, tests, docs, builds, and artifact validation. +- `python tools/check_registry.py` validates registry metadata against every + packaged scalar and radial payload; +- `python tools/check_notebooks.py` smoke-executes each temporary notebook copy + in its own bounded standard Jupyter process, then discards the results; +- `python tools/gen_readme.py` regenerates this README from `docs/index.md`; +- `python tools/release_check.py` runs lint, tests, strict docs, distribution + checks, and clean artifact-installation checks. See the [tools README](https://github.com/DeloneCommons/atomref/blob/main/tools/README.md) -for a short description of each script. +for the maintainer-only data snapshot workflow and command details. diff --git a/docs/notebooks/01-quickstart.ipynb b/docs/notebooks/01-quickstart.ipynb new file mode 100644 index 0000000..50299ef --- /dev/null +++ b/docs/notebooks/01-quickstart.ipynb @@ -0,0 +1,263 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "71066777", + "metadata": {}, + "source": [ + "# atomref scalar-data quickstart\n", + "\n", + "**Goal.** Retrieve common atomic reference values, inspect the provenance of\n", + "a transferred value, and discover the packaged scalar datasets.\n", + "\n", + "**Prerequisites.** Install `atomref`; no optional dependencies or prior\n", + "knowledge of its registry and policy internals are required.\n", + "\n", + "A separate notebook covers the density and pairwise neutral-proatom workflows." + ] + }, + { + "cell_type": "markdown", + "id": "a61df675", + "metadata": {}, + "source": [ + "## Setup and element discovery\n", + "\n", + "Import the public package namespace, normalize one element symbol, and list\n", + "the available reference-data quantities." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "1e9acad5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:14.251072Z", + "iopub.status.busy": "2026-07-14T20:11:14.250764Z", + "iopub.status.idle": "2026-07-14T20:11:14.404186Z", + "shell.execute_reply": "2026-07-14T20:11:14.403144Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Element(z=17, symbol='Cl', name='Chlorine')\n", + "('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length', 'proatomic_density')\n" + ] + } + ], + "source": [ + "import atomref as ar\n", + "\n", + "print(ar.get_element('Cl'))\n", + "print(ar.list_quantities())\n" + ] + }, + { + "cell_type": "markdown", + "id": "b3f97e09", + "metadata": {}, + "source": [ + "## Direct scalar lookups\n", + "\n", + "The convenience `get_*` functions return numbers in angstrom. They are the\n", + "short path when the selected value is all the caller needs." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "fccc4ee6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:14.406117Z", + "iopub.status.busy": "2026-07-14T20:11:14.405944Z", + "iopub.status.idle": "2026-07-14T20:11:14.420709Z", + "shell.execute_reply": "2026-07-14T20:11:14.419620Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.76\n", + "1.5\n" + ] + } + ], + "source": [ + "r_c = ar.get_covalent_radius('C')\n", + "r_vdw = ar.get_vdw_radius('O')\n", + "print(r_c)\n", + "print(r_vdw)\n", + "assert r_c == 0.76\n", + "assert r_vdw == 1.50\n" + ] + }, + { + "cell_type": "markdown", + "id": "0e75e1ac", + "metadata": {}, + "source": [ + "## A lookup with provenance\n", + "\n", + "The preferred van der Waals table has no direct value for Pm. `lookup_*`\n", + "returns the value together with the transfer method and source dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "8696b55d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:14.422614Z", + "iopub.status.busy": "2026-07-14T20:11:14.422449Z", + "iopub.status.idle": "2026-07-14T20:11:14.426883Z", + "shell.execute_reply": "2026-07-14T20:11:14.425877Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.897226539515\n", + "transfer_linear\n", + "(DatasetRef(quantity='atomic_radius', set_id='rahm2016'),)\n" + ] + } + ], + "source": [ + "lookup = ar.lookup_vdw_radius('Pm')\n", + "print(f\"{lookup.value:.12f}\")\n", + "print(lookup.source)\n", + "print(lookup.resolved_from)\n", + "assert lookup.source == 'transfer_linear'\n" + ] + }, + { + "cell_type": "markdown", + "id": "7d19b2d7", + "metadata": {}, + "source": [ + "## Catalog metadata\n", + "\n", + "Quantity and dataset metadata make units, intended role, and source choice\n", + "discoverable instead of leaving them as project-local conventions." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e6f673b1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:14.429516Z", + "iopub.status.busy": "2026-07-14T20:11:14.429265Z", + "iopub.status.idle": "2026-07-14T20:11:14.433868Z", + "shell.execute_reply": "2026-07-14T20:11:14.432970Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "atomic_radius element angstrom\n", + "bondi1964 Bondi van der Waals radii target\n", + "rowland_taylor1996 Rowland & Taylor nonbonded contact radii target\n", + "alvarez2013 Alvarez van der Waals radii target\n", + "chernyshov2020 Chernyshov LoS van der Waals radii target\n" + ] + } + ], + "source": [ + "quantity = ar.get_quantity_info('atomic_radius')\n", + "print(quantity.quantity, quantity.domain, quantity.units)\n", + "\n", + "for info in ar.list_dataset_infos('van_der_waals_radius', usage_role='target'):\n", + " print(info.ref.set_id, info.name, info.usage_role)\n" + ] + }, + { + "cell_type": "markdown", + "id": "791e6178", + "metadata": {}, + "source": [ + "## Direct packaged-set access\n", + "\n", + "Load a target radii table through its convenience helper and a support table\n", + "through the generic dataset reference used by the registry." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f34b8f08", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:14.436281Z", + "iopub.status.busy": "2026-07-14T20:11:14.436089Z", + "iopub.status.idle": "2026-07-14T20:11:14.439659Z", + "shell.execute_reply": "2026-07-14T20:11:14.438664Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.5\n", + "2.83\n" + ] + } + ], + "source": [ + "vdw = ar.get_radii_set('van_der_waals', 'alvarez2013')\n", + "print(vdw.get('O'))\n", + "\n", + "support = ar.get_builtin_set(ar.DatasetRef('atomic_radius', 'rahm2016'))\n", + "print(support.get('Pm'))\n" + ] + }, + { + "cell_type": "markdown", + "id": "680f4dda", + "metadata": {}, + "source": [ + "## What this demonstrated and limitations\n", + "\n", + "The scalar API supports short numeric lookups, provenance-carrying results,\n", + "catalog discovery, and direct packaged-set access. A transferred value is a\n", + "declared policy result, not a hidden measurement. Dataset suitability and\n", + "fallback policy remain choices for the consuming scientific workflow." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/01-quickstart.md b/docs/notebooks/01-quickstart.md deleted file mode 100644 index 12e8813..0000000 --- a/docs/notebooks/01-quickstart.md +++ /dev/null @@ -1,72 +0,0 @@ - - -[Open the original notebook on GitHub](https://github.com/DeloneCommons/atomref/blob/main/notebooks/01-quickstart.ipynb) -# atomref quickstart - -This notebook covers the main public API: element helpers, direct -`get_*` calls, provenance-carrying `lookup_*` calls, and packaged dataset -discovery. -```python -import atomref as ar - -print(ar.get_element('Cl')) -print(ar.list_quantities()) -``` -**Output** -```text -Element(z=17, symbol='Cl', name='Chlorine') -('covalent_radius', 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length') -``` -```python -r_c = ar.get_covalent_radius('C') -r_vdw = ar.get_vdw_radius('O') -print(r_c) -print(r_vdw) -assert r_c == 0.76 -assert r_vdw == 1.50 -``` -**Output** -```text -0.76 -1.5 -``` -```python -lookup = ar.lookup_vdw_radius('Pm') -print(f"{lookup.value:.12f}") -print(lookup.source) -print(lookup.resolved_from) -assert lookup.source == 'transfer_linear' -``` -**Output** -```text -2.897226539515 -transfer_linear -(DatasetRef(quantity='atomic_radius', set_id='rahm2016'),) -``` -```python -quantity = ar.get_quantity_info('atomic_radius') -print(quantity.quantity, quantity.domain, quantity.units) - -for info in ar.list_dataset_infos('van_der_waals_radius', usage_role='target'): - print(info.ref.set_id, info.name, info.usage_role) -``` -**Output** -```text -atomic_radius element angstrom -bondi1964 Bondi van der Waals radii target -rowland_taylor1996 Rowland & Taylor nonbonded contact radii target -alvarez2013 Alvarez van der Waals radii target -chernyshov2020 Chernyshov LoS van der Waals radii target -``` -```python -vdw = ar.get_radii_set('van_der_waals', 'alvarez2013') -print(vdw.get('O')) - -support = ar.get_builtin_set(ar.DatasetRef('atomic_radius', 'rahm2016')) -print(support.get('Pm')) -``` -**Output** -```text -1.5 -2.83 -``` diff --git a/docs/notebooks/02-policies-and-assessment.ipynb b/docs/notebooks/02-policies-and-assessment.ipynb new file mode 100644 index 0000000..6d49757 --- /dev/null +++ b/docs/notebooks/02-policies-and-assessment.ipynb @@ -0,0 +1,233 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3313eadf", + "metadata": {}, + "source": [ + "# Policies and assessment\n", + "\n", + "**Goal.** Define explicit substitution and linear-transfer policies, inspect\n", + "their provenance, and summarize their behavior over several elements.\n", + "\n", + "**Prerequisites.** Familiarity with direct `get_*` and provenance-carrying\n", + "`lookup_*` calls from the scalar-data quickstart is helpful. All values in\n", + "this notebook are scalar radii in angstrom." + ] + }, + { + "cell_type": "markdown", + "id": "3a60ea11", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import the public namespace used for policy, dataset, transfer, and\n", + "assessment objects." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bbc64e7f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:15.758860Z", + "iopub.status.busy": "2026-07-14T20:11:15.758646Z", + "iopub.status.idle": "2026-07-14T20:11:15.895500Z", + "shell.execute_reply": "2026-07-14T20:11:15.894387Z" + } + }, + "outputs": [], + "source": [ + "import atomref as ar\n" + ] + }, + { + "cell_type": "markdown", + "id": "308a0e21", + "metadata": {}, + "source": [ + "## Ordered substitution\n", + "\n", + "This policy prefers Cordero covalent radii and explicitly substitutes the\n", + "legacy CSD covalent table when the preferred table lacks an element. The\n", + "result reports that substitution and the dataset that supplied it." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6e5f9f9b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:15.898380Z", + "iopub.status.busy": "2026-07-14T20:11:15.898198Z", + "iopub.status.idle": "2026-07-14T20:11:15.935476Z", + "shell.execute_reply": "2026-07-14T20:11:15.933556Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "transfer_substitution\n", + "1.540000000000\n", + "(DatasetRef(quantity='covalent_radius', set_id='csd_legacy_cov'),)\n" + ] + } + ], + "source": [ + "covalent_policy = ar.RadiiPolicy(\n", + " kind='covalent',\n", + " base_set='cordero2008',\n", + " transfers=(\n", + " ar.SubstitutionTransfer(\n", + " source=ar.DatasetRef('covalent_radius', 'csd_legacy_cov')\n", + " ),\n", + " ),\n", + ")\n", + "lookup = ar.lookup_covalent_radius('Bk', policy=covalent_policy)\n", + "print(lookup.source)\n", + "print(f\"{lookup.value:.12f}\")\n", + "print(lookup.resolved_from)\n" + ] + }, + { + "cell_type": "markdown", + "id": "40aa7f39", + "metadata": {}, + "source": [ + "## Linear transfer from support data\n", + "\n", + "For a missing van der Waals value, a fitted relation uses the packaged Rahm\n", + "atomic-radius support set. The fit record exposes its coefficients and\n", + "training-point count rather than hiding the inference." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "daeaa220", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:15.938115Z", + "iopub.status.busy": "2026-07-14T20:11:15.937883Z", + "iopub.status.idle": "2026-07-14T20:11:15.949934Z", + "shell.execute_reply": "2026-07-14T20:11:15.948451Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.897226539515\n", + "transfer_linear\n", + "slope=1.135336645553 intercept=-0.315776167399 n=90\n" + ] + } + ], + "source": [ + "vdw_policy = ar.RadiiPolicy(\n", + " kind='van_der_waals',\n", + " base_set='alvarez2013',\n", + " transfers=(\n", + " ar.LinearTransfer(\n", + " predictors=(ar.DatasetRef('atomic_radius', 'rahm2016'),)\n", + " ),\n", + " ),\n", + ")\n", + "lookup = ar.lookup_vdw_radius('Pm', policy=vdw_policy)\n", + "print(f\"{lookup.value:.12f}\")\n", + "print(lookup.source)\n", + "print(\n", + " f\"slope={lookup.fit.coefficients[0]:.12f} intercept={lookup.fit.intercept:.12f} n={lookup.fit.n_points}\"\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "4b36c351", + "metadata": {}, + "source": [ + "## Assess behavior over a selection\n", + "\n", + "Assessment counts direct, transferred, and missing outcomes and can retain a\n", + "per-element result for auditing the policy before it enters an algorithm." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "336054f5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:15.952974Z", + "iopub.status.busy": "2026-07-14T20:11:15.952686Z", + "iopub.status.idle": "2026-07-14T20:11:15.958265Z", + "shell.execute_reply": "2026-07-14T20:11:15.956920Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3 1 0\n", + "C base 1.770000000000\n", + "Xe base 2.060000000000\n", + "Pm transfer_linear 2.897226539515\n", + "Bk base 3.400000000000\n" + ] + } + ], + "source": [ + "assessment = ar.assess_radii_policy(\n", + " ['C', 'Xe', 'Pm', 'Bk'],\n", + " policy=vdw_policy,\n", + " detail=True,\n", + ")\n", + "print(assessment.n_base, assessment.n_transfer_linear, assessment.n_missing)\n", + "for row in assessment.per_element:\n", + " value = 'None' if row.lookup.value is None else f\"{row.lookup.value:.12f}\"\n", + " print(row.symbol, row.lookup.source, value)\n" + ] + }, + { + "cell_type": "markdown", + "id": "b2534084", + "metadata": {}, + "source": [ + "## What this demonstrated and limitations\n", + "\n", + "Policies make fallback order and inferred values explicit, while assessment\n", + "shows how that choice behaves over a requested element set. Transfer quality\n", + "still depends on scientifically appropriate target and support datasets.\n", + "These scalar policies do not apply to proatomic radial profiles." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/02-policies-and-assessment.md b/docs/notebooks/02-policies-and-assessment.md deleted file mode 100644 index 4f6baf6..0000000 --- a/docs/notebooks/02-policies-and-assessment.md +++ /dev/null @@ -1,73 +0,0 @@ - - -[Open the original notebook on GitHub](https://github.com/DeloneCommons/atomref/blob/main/notebooks/02-policies-and-assessment.ipynb) -# Policies and assessment - -This notebook shows how `atomref` resolves missing values through ordered -policy steps and how to inspect policy-level behavior. -```python -import atomref as ar -``` -```python -covalent_policy = ar.RadiiPolicy( - kind='covalent', - base_set='cordero2008', - transfers=( - ar.SubstitutionTransfer( - source=ar.DatasetRef('covalent_radius', 'csd_legacy_cov') - ), - ), -) -lookup = ar.lookup_covalent_radius('Bk', policy=covalent_policy) -print(lookup.source) -print(f"{lookup.value:.12f}") -print(lookup.resolved_from) -``` -**Output** -```text -transfer_substitution -1.540000000000 -(DatasetRef(quantity='covalent_radius', set_id='csd_legacy_cov'),) -``` -```python -vdw_policy = ar.RadiiPolicy( - kind='van_der_waals', - base_set='alvarez2013', - transfers=( - ar.LinearTransfer( - predictors=(ar.DatasetRef('atomic_radius', 'rahm2016'),) - ), - ), -) -lookup = ar.lookup_vdw_radius('Pm', policy=vdw_policy) -print(f"{lookup.value:.12f}") -print(lookup.source) -print( - f"slope={lookup.fit.coefficients[0]:.12f} intercept={lookup.fit.intercept:.12f} n={lookup.fit.n_points}" -) -``` -**Output** -```text -2.897226539515 -transfer_linear -slope=1.135336645553 intercept=-0.315776167399 n=90 -``` -```python -assessment = ar.assess_radii_policy( - ['C', 'Xe', 'Pm', 'Bk'], - policy=vdw_policy, - detail=True, -) -print(assessment.n_base, assessment.n_transfer_linear, assessment.n_missing) -for row in assessment.per_element: - value = 'None' if row.lookup.value is None else f"{row.lookup.value:.12f}" - print(row.symbol, row.lookup.source, value) -``` -**Output** -```text -3 1 0 -C base 1.770000000000 -Xe base 2.060000000000 -Pm transfer_linear 2.897226539515 -Bk base 3.400000000000 -``` diff --git a/docs/notebooks/03-custom-sets-and-discovery.ipynb b/docs/notebooks/03-custom-sets-and-discovery.ipynb new file mode 100644 index 0000000..3166023 --- /dev/null +++ b/docs/notebooks/03-custom-sets-and-discovery.ipynb @@ -0,0 +1,187 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "84b6b5b7", + "metadata": {}, + "source": [ + "# Custom sets and dataset discovery\n", + "\n", + "**Goal.** Build a small immutable user-provided scalar set, use it as a\n", + "policy base, and inspect metadata for packaged alternatives.\n", + "\n", + "**Prerequisites.** Familiarity with element symbols and basic scalar\n", + "lookups is sufficient. The example values are illustrative covalent radii\n", + "in angstrom, not a new curated dataset shipped by `atomref`." + ] + }, + { + "cell_type": "markdown", + "id": "2306eafd", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import the public namespace used to construct sets, policies, and dataset\n", + "references." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b9842810", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:17.456848Z", + "iopub.status.busy": "2026-07-14T20:11:17.456589Z", + "iopub.status.idle": "2026-07-14T20:11:17.615608Z", + "shell.execute_reply": "2026-07-14T20:11:17.614461Z" + } + }, + "outputs": [], + "source": [ + "import atomref as ar\n" + ] + }, + { + "cell_type": "markdown", + "id": "fc8072d3", + "metadata": {}, + "source": [ + "## Define a custom base set\n", + "\n", + "The custom set supplies C and O directly. An explicit substitution step\n", + "uses the packaged Cordero table for N, so each lookup retains whether it\n", + "came from the custom base or the fallback source." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "70ae0eff", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:17.617820Z", + "iopub.status.busy": "2026-07-14T20:11:17.617624Z", + "iopub.status.idle": "2026-07-14T20:11:17.653056Z", + "shell.execute_reply": "2026-07-14T20:11:17.651997Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C LookupResult(value=0.77, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0)\n", + "O LookupResult(value=0.67, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0)\n", + "N LookupResult(value=0.71, source='transfer_substitution', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='cordero2008'),), is_placeholder=False, fit=None, notes=('missing in base set; substituted from transfer source',), transfer_depth=1)\n" + ] + } + ], + "source": [ + "custom_cov = ar.ElementScalarSet.from_mapping(\n", + " ref=ar.DatasetRef(\"covalent_radius\", \"demo_user_cov\"),\n", + " values={\"C\": 0.77, \"O\": 0.67},\n", + " name=\"Demo user covalent set\",\n", + " units=\"angstrom\",\n", + " description=\"Example custom set for notebook usage.\",\n", + " notes=(\"Notebook example\",),\n", + ")\n", + "\n", + "policy = ar.RadiiPolicy(\n", + " kind=\"covalent\",\n", + " base_set=custom_cov,\n", + " transfers=(\n", + " ar.SubstitutionTransfer(\n", + " source=ar.DatasetRef(\"covalent_radius\", \"cordero2008\")\n", + " ),\n", + " ),\n", + ")\n", + "\n", + "for symbol in (\"C\", \"O\", \"N\"):\n", + " print(symbol, ar.lookup_covalent_radius(symbol, policy=policy))\n" + ] + }, + { + "cell_type": "markdown", + "id": "346902c8", + "metadata": {}, + "source": [ + "## Discover packaged dataset metadata\n", + "\n", + "List target van der Waals tables and inspect the scientific classification\n", + "and usage role of the Rahm atomic-radius support dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3b37702a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:11:17.655243Z", + "iopub.status.busy": "2026-07-14T20:11:17.655068Z", + "iopub.status.idle": "2026-07-14T20:11:17.659378Z", + "shell.execute_reply": "2026-07-14T20:11:17.658466Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bondi1964 vdw_compiled compiled_experimental mixed_or_legacy\n", + "rowland_taylor1996 vdw_structural structural condensed_phase\n", + "alvarez2013 vdw_structural structural condensed_phase\n", + "chernyshov2020 vdw_structural_typed_reduced structural condensed_phase\n", + "Rahm isodensity atomic radii (ρ=0.001 e/bohr³)\n", + "atomic_isodensity isolated_atom support\n" + ] + } + ], + "source": [ + "for info in ar.list_radii_set_infos(\"van_der_waals\", usage_role=\"target\"):\n", + " print(info.ref.set_id, info.semantic_class, info.origin_class, info.phase_context)\n", + "\n", + "rahm = ar.get_dataset_info(ar.DatasetRef(\"atomic_radius\", \"rahm2016\"))\n", + "print(rahm.name)\n", + "print(rahm.semantic_class, rahm.phase_context, rahm.usage_role)\n" + ] + }, + { + "cell_type": "markdown", + "id": "be1ea745", + "metadata": {}, + "source": [ + "## What this demonstrated and limitations\n", + "\n", + "A custom `ElementScalarSet` can participate in the same scalar policy\n", + "machinery as packaged tables without modifying the global registry. Users\n", + "remain responsible for the provenance, units, coverage, and scientific\n", + "fitness of their own values. Custom radial-profile datasets are outside the\n", + "current public scope." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/03-custom-sets-and-discovery.md b/docs/notebooks/03-custom-sets-and-discovery.md deleted file mode 100644 index 47138bf..0000000 --- a/docs/notebooks/03-custom-sets-and-discovery.md +++ /dev/null @@ -1,56 +0,0 @@ - - -[Open the original notebook on GitHub](https://github.com/DeloneCommons/atomref/blob/main/notebooks/03-custom-sets-and-discovery.ipynb) -# Custom sets and dataset discovery - -This notebook shows how to define a small user-provided set, plug it into a -policy, and inspect the packaged dataset catalog. -```python -import atomref as ar -``` -```python -custom_cov = ar.ElementScalarSet.from_mapping( - ref=ar.DatasetRef("covalent_radius", "demo_user_cov"), - values={"C": 0.77, "O": 0.67}, - name="Demo user covalent set", - units="angstrom", - description="Example custom set for notebook usage.", - notes=("Notebook example",), -) - -policy = ar.RadiiPolicy( - kind="covalent", - base_set=custom_cov, - transfers=( - ar.SubstitutionTransfer( - source=ar.DatasetRef("covalent_radius", "cordero2008") - ), - ), -) - -for symbol in ("C", "O", "N"): - print(symbol, ar.lookup_covalent_radius(symbol, policy=policy)) -``` -**Output** -```text -C LookupResult(value=0.77, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0) -O LookupResult(value=0.67, source='base', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'),), is_placeholder=False, fit=None, notes=(), transfer_depth=0) -N LookupResult(value=0.71, source='transfer_substitution', target=DatasetRef(quantity='covalent_radius', set_id='demo_user_cov'), resolved_from=(DatasetRef(quantity='covalent_radius', set_id='cordero2008'),), is_placeholder=False, fit=None, notes=('missing in base set; substituted from transfer source',), transfer_depth=1) -``` -```python -for info in ar.list_radii_set_infos("van_der_waals", usage_role="target"): - print(info.ref.set_id, info.semantic_class, info.origin_class, info.phase_context) - -rahm = ar.get_dataset_info(ar.DatasetRef("atomic_radius", "rahm2016")) -print(rahm.name) -print(rahm.semantic_class, rahm.phase_context, rahm.usage_role) -``` -**Output** -```text -bondi1964 vdw_compiled compiled_experimental mixed_or_legacy -rowland_taylor1996 vdw_structural structural condensed_phase -alvarez2013 vdw_structural structural condensed_phase -chernyshov2020 vdw_structural_typed_reduced structural condensed_phase -Rahm isodensity atomic radii (ρ=0.001 e/bohr³) -atomic_isodensity isolated_atom support -``` diff --git a/docs/notebooks/04-ias-method-selection-study.ipynb b/docs/notebooks/04-ias-method-selection-study.ipynb new file mode 100644 index 0000000..291d78f --- /dev/null +++ b/docs/notebooks/04-ias-method-selection-study.ipynb @@ -0,0 +1,1102 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cdde20c5", + "metadata": {}, + "source": [ + "\n", + "# Choosing a practical neutral-proatom interatomic-surface (IAS) proxy\n", + "\n", + "This notebook documents the numerical study used to select practical parameters\n", + "for the IAS estimators in `atomref`. It compares two pairwise quantities derived\n", + "from the packaged neutral spherical proatomic densities:\n", + "\n", + "1. a **stable proatomic boundary**, based on equal proatomic contributions or a\n", + " midpoint between fixed low-density contours;\n", + "2. an optional **promolecular line-density minimum**, searched only where both\n", + " proatomic contributions remain above the cutoff and only to a declared\n", + " practical spatial resolution.\n", + "\n", + "The calculations below form a standalone numerical sensitivity study rather\n", + "than the package API and do not claim to locate an exact QTAIM zero-flux\n", + "surface. Local helper functions isolate comparisons behind the documented\n", + "cutoff and resolution policy.\n", + "\n", + "**Prerequisites.** This supporting study assumes familiarity with radial\n", + "electron densities, one-dimensional minimization, and the distinction between\n", + "neutral-proatom models and molecular-density QTAIM analysis." + ] + }, + { + "cell_type": "markdown", + "id": "59023f54", + "metadata": {}, + "source": [ + "\n", + "## Numerical policy examined here\n", + "\n", + "The per-atom neutral-tail cutoff is\n", + "\n", + "$$\n", + "\\rho_c = 10^{-4}\\ \\mathrm{electron/bohr^3}.\n", + "$$\n", + "\n", + "The default boundary mode uses homonuclear symmetry, equal proatomic\n", + "contributions in the overlap region, and the midpoint between cutoff contours\n", + "when a low-density gap opens. The optional minimum mode searches only the\n", + "interval where both contributions are at least $\\rho_c$, and coalesces\n", + "features below `0.01 bohr` (about `0.0053 Å`).\n" + ] + }, + { + "cell_type": "markdown", + "id": "fcdd3855", + "metadata": {}, + "source": [ + "## Scientific context and references\n", + "\n", + "The two modes answer different questions.\n", + "\n", + "- QTAIM defines atomic basins through zero-flux surfaces of the **molecular**\n", + " electron density; a critical point on an internuclear path is therefore a\n", + " molecular-density object, not something that neutral spherical proatoms can\n", + " reproduce exactly. See R. F. W. Bader, *Chemical Reviews* **91** (1991),\n", + " 893–928, [doi:10.1021/cr00005a013](https://doi.org/10.1021/cr00005a013).\n", + "- Hirshfeld's stockholder construction assigns molecular density in proportion\n", + " to free-atom reference densities. In the present two-proatom line model,\n", + " equal neutral-proatom densities therefore give equal pairwise stockholder\n", + " contributions. See F. L. Hirshfeld, *Theoretica Chimica Acta* **44** (1977),\n", + " 129–138, [doi:10.1007/BF00549096](https://doi.org/10.1007/BF00549096).\n", + "- Radial-density constructions can be useful atom/bond models, but they remain\n", + " model definitions that need comparison with molecular-density results. See\n", + " P. L. Warburton, R. A. Poirier, and D. Nippard, *J. Phys. Chem. A* **115**\n", + " (2011), 852–867, [doi:10.1021/jp1093417](https://doi.org/10.1021/jp1093417).\n", + "\n", + "These references motivate the interpretation. The numerical experiments below\n", + "support only the documented numerical policy for the supplied neutral-proatom data;\n", + "they do not validate either mode as an exact QTAIM surface.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "51dd32b7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:29.498724Z", + "iopub.status.busy": "2026-07-14T20:22:29.498536Z", + "iopub.status.idle": "2026-07-14T20:22:30.124758Z", + "shell.execute_reply": "2026-07-14T20:22:30.123737Z" + } + }, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from bisect import bisect_right\n", + "from dataclasses import dataclass\n", + "from functools import lru_cache\n", + "import math\n", + "import random\n", + "import time\n", + "\n", + "from pathlib import Path\n", + "import sys\n", + "\n", + "ROOT = Path.cwd().resolve()\n", + "if not (ROOT / \"src\" / \"atomref\").is_dir():\n", + " for candidate in (ROOT.parent, ROOT.parent.parent):\n", + " if (candidate / \"src\" / \"atomref\").is_dir():\n", + " ROOT = candidate\n", + " break\n", + "if not (ROOT / \"src\" / \"atomref\").is_dir():\n", + " raise RuntimeError(\"Run this notebook from the atomref repository checkout\")\n", + "sys.path.insert(0, str(ROOT / \"src\"))\n", + "\n", + "import atomref as ar\n", + "\n", + "try:\n", + " import matplotlib.pyplot as plt\n", + "except ImportError: # The package itself has no plotting dependency.\n", + " plt = None\n", + "\n", + "ASSET_DIR = ROOT / \"docs\" / \"assets\" / \"ias-method-study\"\n", + "ASSET_DIR.mkdir(parents=True, exist_ok=True)\n", + "\n", + "TAIL_CUTOFF = 1.0e-4\n", + "PRACTICAL_RESOLUTION = 0.01\n", + "INITIAL_STEP = 0.02\n", + "CONFIRM_STEP = 0.01\n", + "FALLBACK_STEP = 0.005\n", + "COMPETITIVE_RELATIVE_GAP = 1.0e-4\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class StudyEstimate:\n", + " method: str\n", + " status: str\n", + " position: float | None\n", + " rho_a: float | None\n", + " rho_b: float | None\n", + " rho_sum: float | None\n", + " contour_separation: float\n", + " search_resolution: float | None = None\n", + " search_converged: bool | None = None\n", + " alternative_position: float | None = None\n", + " relative_depth_gap: float | None = None\n", + " probes: int = 0\n", + "\n", + "\n", + "@lru_cache(maxsize=None)\n", + "def profile(symbol: str):\n", + " result = ar.get_proatomic_density_profile(symbol)\n", + " if result is None:\n", + " raise ValueError(f\"missing profile: {symbol}\")\n", + " return result\n", + "\n", + "\n", + "@lru_cache(maxsize=None)\n", + "def cutoff_radius(symbol: str) -> float:\n", + " p = profile(symbol)\n", + " right = next(i for i, value in enumerate(p.densities) if value <= TAIL_CUTOFF)\n", + " if p.densities[right] == TAIL_CUTOFF:\n", + " return p.radii[right]\n", + " left = right - 1\n", + " lr0 = math.log(p.radii[left])\n", + " lr1 = math.log(p.radii[right])\n", + " ld0 = math.log(p.densities[left])\n", + " ld1 = math.log(p.densities[right])\n", + " fraction = (math.log(TAIL_CUTOFF) - ld0) / (ld1 - ld0)\n", + " return math.exp(lr0 + fraction * (lr1 - lr0))\n", + "\n", + "\n", + "def rho(symbol: str, radius: float) -> float:\n", + " return profile(symbol)._evaluate_bohr(radius)\n", + "\n", + "\n", + "def objective(atom_a: str, atom_b: str, x: float, distance: float) -> float:\n", + " return rho(atom_a, x) + rho(atom_b, distance - x)\n", + "\n", + "\n", + "def continuous_log_density(symbol: str, radius: float) -> float:\n", + " p = profile(symbol)\n", + " if radius <= p.radii[0]:\n", + " return math.log(p.densities[0])\n", + " index = min(bisect_right(p.radii, radius) - 1, len(p.radii) - 2)\n", + " lr0 = math.log(p.radii[index])\n", + " lr1 = math.log(p.radii[index + 1])\n", + " ld0 = math.log(p.densities[index])\n", + " ld1 = math.log(p.densities[index + 1])\n", + " fraction = (math.log(radius) - lr0) / (lr1 - lr0)\n", + " return ld0 + fraction * (ld1 - ld0)\n", + "\n", + "\n", + "def equal_contribution_position(atom_a: str, atom_b: str, distance: float) -> float | None:\n", + " if atom_a == atom_b:\n", + " return distance / 2.0\n", + "\n", + " def difference(x: float) -> float:\n", + " return continuous_log_density(atom_a, x) - continuous_log_density(\n", + " atom_b, distance - x\n", + " )\n", + "\n", + " left_value = difference(0.0)\n", + " right_value = difference(distance)\n", + " if left_value == 0.0:\n", + " return 0.0\n", + " if right_value == 0.0:\n", + " return distance\n", + " if left_value * right_value > 0.0:\n", + " return None\n", + "\n", + " left = 0.0\n", + " right = distance\n", + " for _ in range(100):\n", + " midpoint = (left + right) / 2.0\n", + " value = difference(midpoint)\n", + " if right - left <= 1.0e-10:\n", + " break\n", + " if value > 0.0:\n", + " left = midpoint\n", + " else:\n", + " right = midpoint\n", + " return (left + right) / 2.0\n", + "\n", + "\n", + "def component_values(atom_a: str, atom_b: str, distance: float, x: float):\n", + " value_a = rho(atom_a, x)\n", + " value_b = rho(atom_b, distance - x)\n", + " return value_a, value_b, value_a + value_b\n", + "\n", + "\n", + "def boundary_estimate(atom_a: str, atom_b: str, distance: float) -> StudyEstimate:\n", + " radius_a = cutoff_radius(atom_a)\n", + " radius_b = cutoff_radius(atom_b)\n", + " separation = distance - radius_a - radius_b\n", + "\n", + " if atom_a == atom_b:\n", + " x = distance / 2.0\n", + " value_a, value_b, total = component_values(atom_a, atom_b, distance, x)\n", + " status = \"low_density_gap\" if separation > 0.0 else \"ok\"\n", + " return StudyEstimate(\n", + " \"homonuclear_midpoint\", status, x, value_a, value_b, total, separation\n", + " )\n", + "\n", + " if separation > 0.0:\n", + " x = (radius_a + distance - radius_b) / 2.0\n", + " value_a, value_b, total = component_values(atom_a, atom_b, distance, x)\n", + " return StudyEstimate(\n", + " \"cutoff_gap_midpoint\",\n", + " \"low_density_gap\",\n", + " x,\n", + " value_a,\n", + " value_b,\n", + " total,\n", + " separation,\n", + " )\n", + "\n", + " x = equal_contribution_position(atom_a, atom_b, distance)\n", + " if x is None or not (0.0 < x < distance):\n", + " return StudyEstimate(\n", + " \"none\", \"one_atom_dominates\", None, None, None, None, separation\n", + " )\n", + " value_a, value_b, total = component_values(atom_a, atom_b, distance, x)\n", + " return StudyEstimate(\n", + " \"equal_proatom_density\", \"ok\", x, value_a, value_b, total, separation\n", + " )\n", + "\n", + "\n", + "def overlap_interval(atom_a: str, atom_b: str, distance: float) -> tuple[float, float]:\n", + " return (\n", + " max(0.0, distance - cutoff_radius(atom_b)),\n", + " min(distance, cutoff_radius(atom_a)),\n", + " )\n", + "\n", + "\n", + "def golden_minimize(function, left: float, right: float):\n", + " ratio = (math.sqrt(5.0) - 1.0) / 2.0\n", + " x1 = right - ratio * (right - left)\n", + " x2 = left + ratio * (right - left)\n", + " f1 = function(x1)\n", + " f2 = function(x2)\n", + " for _ in range(70):\n", + " if f1 <= f2:\n", + " right, x2, f2 = x2, x1, f1\n", + " x1 = right - ratio * (right - left)\n", + " f1 = function(x1)\n", + " else:\n", + " left, x1, f1 = x1, x2, f2\n", + " x2 = left + ratio * (right - left)\n", + " f2 = function(x2)\n", + " return (x1, f1) if f1 <= f2 else (x2, f2)\n", + "\n", + "\n", + "def grid_valleys(atom_a: str, atom_b: str, distance: float, step: float):\n", + " left, right = overlap_interval(atom_a, atom_b, distance)\n", + " if right <= left:\n", + " return [], 0\n", + "\n", + " count = max(3, math.ceil((right - left) / step) + 1)\n", + " coordinates = [left + (right - left) * i / (count - 1) for i in range(count)]\n", + " equal = equal_contribution_position(atom_a, atom_b, distance)\n", + " if equal is not None and left < equal < right:\n", + " coordinates.append(equal)\n", + " coordinates = sorted(set(coordinates))\n", + "\n", + " values = [objective(atom_a, atom_b, x, distance) for x in coordinates]\n", + " function = lambda x: objective(atom_a, atom_b, x, distance)\n", + " candidates = []\n", + " for index in range(1, len(coordinates) - 1):\n", + " if values[index] <= values[index - 1] and values[index] <= values[index + 1]:\n", + " candidates.append(\n", + " golden_minimize(\n", + " function, coordinates[index - 1], coordinates[index + 1]\n", + " )\n", + " )\n", + " return candidates, len(coordinates)\n", + "\n", + "\n", + "def coalesce(candidates, resolution: float = PRACTICAL_RESOLUTION):\n", + " if not candidates:\n", + " return []\n", + " ordered = sorted(candidates, key=lambda item: item[0])\n", + " groups: list[list[tuple[float, float]]] = []\n", + " for candidate in ordered:\n", + " if not groups or candidate[0] - groups[-1][-1][0] > resolution:\n", + " groups.append([candidate])\n", + " else:\n", + " groups[-1].append(candidate)\n", + " return [min(group, key=lambda item: item[1]) for group in groups]\n", + "\n", + "\n", + "def selections_compatible(first, second) -> bool:\n", + " if first is None or second is None:\n", + " return first is second\n", + " position_ok = abs(first[0] - second[0]) <= PRACTICAL_RESOLUTION\n", + " density_ok = abs(first[1] - second[1]) <= (\n", + " COMPETITIVE_RELATIVE_GAP * min(first[1], second[1])\n", + " )\n", + " return position_ok and density_ok\n", + "\n", + "\n", + "def practical_minimum_estimate(atom_a: str, atom_b: str, distance: float) -> StudyEstimate:\n", + " separation = distance - cutoff_radius(atom_a) - cutoff_radius(atom_b)\n", + " if atom_a == atom_b:\n", + " x = distance / 2.0\n", + " value_a, value_b, total = component_values(atom_a, atom_b, distance, x)\n", + " status = \"low_density_gap\" if separation > 0.0 else \"ok\"\n", + " return StudyEstimate(\n", + " \"homonuclear_midpoint\",\n", + " status,\n", + " x,\n", + " value_a,\n", + " value_b,\n", + " total,\n", + " separation,\n", + " search_resolution=PRACTICAL_RESOLUTION,\n", + " search_converged=True,\n", + " )\n", + "\n", + " left, right = overlap_interval(atom_a, atom_b, distance)\n", + " if right <= left:\n", + " return StudyEstimate(\n", + " \"none\", \"low_density_gap\", None, None, None, None, separation\n", + " )\n", + "\n", + " all_candidates = []\n", + " pass_best = []\n", + " probes = 0\n", + " for step in (INITIAL_STEP, CONFIRM_STEP):\n", + " candidates, count = grid_valleys(atom_a, atom_b, distance, step)\n", + " probes += count\n", + " all_candidates.extend(candidates)\n", + " pass_best.append(min(candidates, key=lambda item: item[1]) if candidates else None)\n", + "\n", + " need_fallback = not selections_compatible(pass_best[0], pass_best[1])\n", + " if need_fallback:\n", + " candidates, count = grid_valleys(atom_a, atom_b, distance, FALLBACK_STEP)\n", + " probes += count\n", + " all_candidates.extend(candidates)\n", + " pass_best.append(min(candidates, key=lambda item: item[1]) if candidates else None)\n", + "\n", + " resolved = coalesce(all_candidates)\n", + " if not resolved:\n", + " return StudyEstimate(\n", + " \"none\",\n", + " \"no_resolved_interior_minimum\",\n", + " None,\n", + " None,\n", + " None,\n", + " None,\n", + " separation,\n", + " search_resolution=FALLBACK_STEP if need_fallback else CONFIRM_STEP,\n", + " search_converged=not need_fallback,\n", + " probes=probes,\n", + " )\n", + "\n", + " ordered = sorted(resolved, key=lambda item: item[1])\n", + " selected = ordered[0]\n", + " alternative = ordered[1] if len(ordered) > 1 else None\n", + " converged = (\n", + " selections_compatible(pass_best[-2], pass_best[-1])\n", + " if need_fallback\n", + " else True\n", + " )\n", + "\n", + " status = \"ok\"\n", + " relative_gap = None\n", + " if alternative is not None:\n", + " relative_gap = (alternative[1] - selected[1]) / selected[1]\n", + " if relative_gap <= COMPETITIVE_RELATIVE_GAP:\n", + " status = \"ambiguous_competing_minima\"\n", + " if not converged:\n", + " status = \"search_unstable\"\n", + "\n", + " boundary_value = min(\n", + " objective(atom_a, atom_b, 0.0, distance),\n", + " objective(atom_a, atom_b, distance, distance),\n", + " )\n", + " if boundary_value < selected[1] * (1.0 - 2.0e-14):\n", + " status = \"boundary_dominated\"\n", + "\n", + " value_a, value_b, total = component_values(\n", + " atom_a, atom_b, distance, selected[0]\n", + " )\n", + " return StudyEstimate(\n", + " \"promolecular_density_minimum\",\n", + " status,\n", + " selected[0],\n", + " value_a,\n", + " value_b,\n", + " total,\n", + " separation,\n", + " search_resolution=FALLBACK_STEP if need_fallback else CONFIRM_STEP,\n", + " search_converged=converged,\n", + " alternative_position=None if alternative is None else alternative[0],\n", + " relative_depth_gap=relative_gap,\n", + " probes=probes,\n", + " )\n", + "\n", + "\n", + "def dense_reference_minimum(atom_a: str, atom_b: str, distance: float, step=0.001):\n", + " if atom_a == atom_b:\n", + " x = distance / 2.0\n", + " return x, objective(atom_a, atom_b, x, distance)\n", + " candidates, _ = grid_valleys(atom_a, atom_b, distance, step)\n", + " if not candidates:\n", + " return None\n", + " return min(candidates, key=lambda item: item[1])\n", + "\n", + "\n", + "def deterministic_cases(count: int, seed: int = 20260713):\n", + " symbols = [element.symbol for element in ar.iter_elements() if element.z <= 103]\n", + " generator = random.Random(seed)\n", + " cases = []\n", + " for index in range(count):\n", + " atom_a = generator.choice(symbols)\n", + " atom_b = generator.choice(symbols)\n", + " if index % 2:\n", + " distance = generator.uniform(0.2, 12.0)\n", + " else:\n", + " distance = 10.0 ** generator.uniform(math.log10(0.2), math.log10(12.0))\n", + " cases.append((atom_a, atom_b, distance))\n", + " return cases\n", + "\n", + "\n", + "def run_comparison(count=300):\n", + " cases = deterministic_cases(count)\n", + " practical = []\n", + " reference = []\n", + " start = time.perf_counter()\n", + " for case in cases:\n", + " practical.append(practical_minimum_estimate(*case))\n", + " practical_seconds = time.perf_counter() - start\n", + "\n", + " start = time.perf_counter()\n", + " for case in cases:\n", + " reference.append(dense_reference_minimum(*case))\n", + " reference_seconds = time.perf_counter() - start\n", + "\n", + " status_mismatch = 0\n", + " errors = []\n", + " for case, estimate, expected in zip(cases, practical, reference):\n", + " found = estimate.position is not None\n", + " expected_found = expected is not None\n", + " if found != expected_found:\n", + " # A sub-resolution dense-reference minimum can intentionally be absent.\n", + " status_mismatch += 1\n", + " continue\n", + " if found:\n", + " dx = abs(estimate.position - expected[0])\n", + " relative_density = abs(estimate.rho_sum - expected[1]) / expected[1]\n", + " errors.append((dx, relative_density, case, estimate, expected))\n", + "\n", + " return {\n", + " \"cases\": cases,\n", + " \"practical\": practical,\n", + " \"reference\": reference,\n", + " \"practical_seconds\": practical_seconds,\n", + " \"reference_seconds\": reference_seconds,\n", + " \"status_mismatch\": status_mismatch,\n", + " \"errors\": errors,\n", + " }\n" + ] + }, + { + "cell_type": "markdown", + "id": "bedc9faf", + "metadata": {}, + "source": [ + "\n", + "## Cutoff radii across H–Lr\n", + "\n", + "Every packaged profile is monotone over its stored positive segments, so the\n", + "cutoff radius is unique and can be obtained by directly inverting one log–log\n", + "segment. The plot shows that the chosen contour is deliberately diffuse: the\n", + "radii span roughly 1.8–4.2 Å.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e69152d0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:30.127567Z", + "iopub.status.busy": "2026-07-14T20:22:30.127316Z", + "iopub.status.idle": "2026-07-14T20:22:30.657102Z", + "shell.execute_reply": "2026-07-14T20:22:30.655974Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Smallest cutoff radius: He = 3.373985 bohr (1.785436 Å)\n", + "Largest cutoff radius: Ba = 7.966363 bohr (4.215618 Å)\n", + "Representative radii:\n", + " H: 4.128012 bohr = 2.184450 Å\n", + " He: 3.373985 bohr = 1.785436 Å\n", + " Li: 6.469571 bohr = 3.423549 Å\n", + " C: 4.895923 bohr = 2.590811 Å\n", + " O: 4.333035 bohr = 2.292943 Å\n", + " Fe: 6.132435 bohr = 3.245145 Å\n", + " U: 7.398370 bohr = 3.915049 Å\n", + " Lr: 7.736203 bohr = 4.093822 Å\n" + ] + }, + { + "data": { + "image/png": "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JUta/L4///wzEhMT8f777+Ps2bP4+uuv0a5dO/z6668mx504cQLdunXDvffei3379mHXrl0YP348PvroIwD6c5VWqzV7rjpx4gR/rho1ahS2bNmC77//HpWVldBoNHjggQfQu3dvbNy4EUePHsXMmTPRu3dvZGRk8N//7Nmz/Bq5cePGYfPmzdi5cycGDx6Ml19+ucnPdebMGVy5cqVJYFdTU4Px48fjp59+QmpqKiZPnowxY8agqqqKP0apVGLGjBno378/tm7disOHD2PatGno37+/2czftWvXMG7cOGzbtg3btm3DgAED8Pbbb5scs3z5cnz22WdYtGgRHn74YRw4cABr167F4cOHMW7cOCxduhRfffUVJkyYgMDAQIwcObLF1460gCWkEbVazQJgH3roIZZlWXbEiBFsbGwsW11dzbIsy+7YsYMFwP7000/8Y5544gnWx8eHPXToEH9bVVUV27t3b3bUqFH8bS+99BJr7m3HPeeOHTv423r37s2OHz++ybF//PEHC4Dt378/m5eXx7Isy9bW1rIVFRVmf56ioiI2Li6OfeaZZ/jbTpw4wQJgf/vttxZ/H//5z39YhULBzpkzh1Wr1SzLsqxOp2Pvv/9+VqFQsJcvX2ZZtuH3Fhsby27ZsoV/fH5+Pnv69GlWJpOx999/P6vValmWZVmtVsvOnDmTlcvlbHp6erNjeO+991hfX1/2+vXr/G0TJ05kU1JSmhx76NAhViKRsI899hh/m1qtZu+66y7Wz8+PzcjI4G8HwMbFxbG7du3ib1u2bBnLMAw7e/Zs9q+//uJvX758OQuAPX78eLNj/eCDD1gA7B133MG/Z/Lz8y0ef/bsWVYul7PLly/nb/vyyy9ZAOzq1av523Jzc9l+/fqxANg//viDv33kyJHs0KFD+f9v3bqVBcDu2bPH5Ptwr89rr73GsizLVlZWshKJhF24cKHJcbm5uS2+HuacOXOGlclk7Ndff83ftnLlShYAe+7cOaufZ8uWLSwA9n//+5/J7VOmTGEBmP1dvvXWWywAq74uXbrU4hj27dvHAmAXL17c5L7NmzezANhvv/22yX1vv/02C4A9e/as1T9vY7t372ZfffXVFo+7fPky6+/vz86YMYP/u2RZlv3uu+9YhmHYffv28bclJSWx9913H////fv3sxKJhJ0/fz6r0+n42998802T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FBTg8OHD8PPz43ev6NevH6ZPn45nn30W6enp8PPzQ2pqKp544gk8/PDDJs85ZcoUPPLII3j88cfRrl07hIaGNjmmsffeew9Tp07FuHHjMGzYMBw/fhxBQUG4+eabcfDgQbt/dj8/P6SkpGDcuHEYMmQIjh8/js2bN+Pjjz82O23W2MCBA/HRRx/hpZdewtmzZ3HjjTfi8OHD2LFjBxYvXsz3frrzzjsxePBgzJgxAw899BB0Oh22b9+OF198sUlANnnyZHz77bd8mxmZTIbnn38eY8aMwYIFC/D2228jLS0NvXv3xr///ou9e/di+fLlTdbFuIpMJsO7776LF198ETU1NejatSv27NmDcePGmSz+BoBnn30We/bswcSJE3H//fcjIiICu3btwty5c1vcHF6hUOCdd97B448/DrVajZSUFOzduxfPPfccvvvuO/646upq3HrrrWjfvj169eqFwMBA/P3337h48WKzW9n5+PhgwYIFeO2111BVVYXOnTtj165dmDhxYpP+aUOHDkVsbCyeffZZfpcFS33sdDodTp06hTFjxuDDDz/ElStXEB0djU2bNqGgoABbtmyxuHuFUGvslixZAo1Gwwc2e/bsgUajAaCfouSm1N5++20cPXoUo0aNwkMPPYT6+np88803uP/++/HQQw85PA5r9O7dG6tWrcJDDz2Effv2YcyYMfD19cXFixdx4MABLF261OLOOBMnTsTSpUvxwgsvYMOGDRgyZAhYlsW5c+dw8uRJrFq1CsnJyYiJicH69esxdepU9OnTBxMnToRcLsfff/+NMWPG8DMUU6ZMwTPPPIMnn3wSycnJfB+75ixYsABpaWkYN24cZs2ahbCwMKxbtw5VVVXYvHmzVcsOjG3btg1qtRq33Xab2fsfffRRzJ49G/3794dUKsX//vc/JCYmYvHixfwxCxcuxJkzZzBixAjMmjULQUFB+PXXX6HVarF69eoWe1gScaDAjjQhkUgwb948k+ajnGeeeQalpaXQarUmxQRBQUHYtGkTjhw5gn/++QdlZWVISUnBQw891KSC8pdffsG6detw4sQJxMXF4eWXX0ZJSQnmzZuHpKQk/ri5c+ciMTERBw4cQGFhIZ9169SpE+bNm2e2cnHSpEk4ffo0Nm3ahOrqajz66KOYMGECfvnlF5NFv9HR0Zg3bx66dOli9e+F68v3119/YdCgQXjvvffQu3fvJr83S2uz5s2bhzvvvJO/UI8fPx7Lli1Dhw4d+GPkcjn27t2L3377DefOnUNkZCS2b98OmUyGefPmmUyD33LLLfj333+xe/duFBUVmUwHvfnmm5g2bRq2bNmC4uJi3HHHHfj2229Nfr/cmMxlBubNm2e26u+5557D4MGDm/09DRw4EPPmzTNbgPDCCy/wzZC1Wi0+/PBDDB48GIGBgSZZO7lcjs2bN2PTpk18g+J169ZBq9Vi3rx5JlNC5rYUe+yxx9C7d29s374dPj4++PLLL/n3Dff6dOjQAefOncOuXbtw4sQJ1NXV4f7778e6detaXOj98ssvY8SIEfj777+h0+nw8ccfY+DAgfDx8THJiAUFBeHIkSP4/fffkZeXx/c7M+fSpUuorq7GzJkz8cYbb2DZsmU4ceIEbrnlFjz//PN8NteZuP6Rvr6+/LQv1yzbOOvt5+eHHTt2YMuWLThy5Ah8fX2xceNGl/cgmzJlCsaOHYtNmzbhwoULUCgUmDJlCr799luT6chHHnmkyYeHJ598ElOmTMHmzZtx9epVBAcH48EHH8SECRMgl8v540aPHo2rV69iw4YNOH/+PMLCwvD555+bTM0++eST6NChAw4dOoSCggL+/di1a1fMmzePz9wa8/Pzw/bt27Fr1y6+QfFrr72Gu+++22TtWocOHTBv3rwmayt9fHwwb948/u9xw4YNGDRoUJOK6aCgIMybNw833XQTnnvuOaxatQrXrl3Dhx9+iOnTp5v8nQYHB+Off/7B9u3bcfDgQajVarz33nu46667TKa7uTXJxlW6AODv74958+aZNCmePn06/+GAuAbDOrLSl5BW4NFHH8Xq1aupIzpxqlWrVuHee+/ls6yEWEur1SI6OhovvfQSXnzxRXcPh7gZrbEjhBAROHHiBBQKRZO2OoS0pLCwEA888IBJ30fSetFULCGEiMDx48fRo0cPk2lAQqwRFxfXYoUvaT0osCOkBZY2BidESGvXrrV5wTwhhDRGa+wIIYQQQrwErbEjhBBCCPESFNgRQgghhHiJVr3GTqfTIS8vD0FBQdR4kRBCCCGixLIsqqqqEB8f3+IWdq06sMvLy2vSKZ4QQgghRIyys7ObNKturFUHdlyn++zs7CZ7PBJCCCGEiEFlZSUSEhKa7KttTqsO7Ljp1+DgYArsCCGEECJq1iwbo+IJQgghhBAvQYEdIYQQQoiXoMCOEEIIIcRLUGBHCCGEEOIlKLAjhBBCCPESFNgRQgghhHgJCuwIIYQQQrwEBXaEEEIIIV6CAjtCCCGEEC9BgR0hhBBCiJegwI4QQgghxEtQYEcIIYQQ4iUosCOEEEII8RIydw+AEEKIKZ2ORXG1ErnldSivVaN/chiCfOXuHhYhxANQYEcIISJQVqPCS7+fwvn8KlyvqINay/L3TevfFv83pbcbR0cI8RQU2BFCiAj8c7EI288W8P+XMECYvw9KalT493KJG0dGCPEkFNgRQogI1Kq0AICB7cLxyfQ+iAlSoF6jQ8+3/0JueR0KK+sRHezr5lESQsSOiicIIUQEVBp9YBcZqECbUD/IpBIEKmToEhMEADiRXe7G0ZHWpqCyHhvScqHTsS0fTESFAjtCCBEBlVYHAPCRmZ6W+ySEAgDSKLAjLlJcrcTdX6TimdVpWHci193DITaiwI4QQkRApTEEdlLzgd2JrDJXD4m0QvVqLR758Shyy+sAAFtPX3fziIitKLAjhBAR4AI7hdz0tHxDYhgA4HROBbQ0LUaciGVZvLj2FI5nlcNPLgUA7LtcjBqlxs0jI7agwI4QQkRAqTWfsesYHYgAHylqVFpcKqxyx9BIK/HZrsv482QeZBIG387uj6QIf6g0Ouy7VOTuoREbUGBHCCEiwE/FNlpjJ5Uw6M1Px5a7eFSktfjzZB4W77wIAHhvUg8M6RiJm7vHAAC2pxc091AiMhTYEUKICCgtBHaAUQEFBXbECdKyy/HCbycBAA8Pb4cZAxIBAOO6xwIAdp0vhNqQUSbiR4EdIYSIgKWMHWBUQJFNBRREeCv/vQqVRoebukbj5Vu68bf3SwpDeIAPKurUOHK11I0jJLagwI4QQkTAUlUsAPRJDAUAXCqsRlW92pXDIq1AZZ3+PTWhRyykEoa/XSphMLZbNACY7IpCxI0CO0IIEQG+KtZMxi46yBdtQv3AsvrqWEKEVK/mKrKlTe672TAdu+NsAViWqrI9AQV2hBAiApYaFHNuMGTtaAcKIjSlYdcTcx8qhnWKhJ9citzyOqTnVbp6aMQOFNgRQogINLfGDjBuVFzuohGR1kLZTLbYVy7FiM6RAGg61lNQYEcIISLQsMau6XQY0JCxS8supykxIigusPM1MxULNEzHbk/Pd9mYiP0osCOEEBHgGhSby5oAQEp8CORSBsXVSuSU1blyaMTL1astT8UCwJiu0ZBKGJzPr0J2aa0rhyZ6YvyQRYEdIYSIQEtTsb5yKbrFBQPQZ+0IEUrDVKz5jF1YgA8GJIcDoOnYxv6+UISUN7fhoe+PuHsoPArsCCFEBFSGBeyWAjuA1tkR51ByGTu55ffeOH4XCpqONVZSo0KNSssXP4kBBXaEECICze08wWlYZ2ddo+I6ldbhcRHv19IaO6AhsDuSWYrSGpVLxuUJSqqVAICIAB83j6QBBXaEECICzTUo5vRJCAMAnMmr5I+35KeD19Dj7b8w/7eTfDsLQhpjWbbZqlhOQrg/usUFQ8cC+y4VuWp4oscFuRGBCjePpAEFdoQQIgKqFoonACA5wh+h/nKoNDqcu265p1heeR3e33wOWh2L347lYNa3h1FGWRZihtLoA0Jz7z0AGNRev86OlgI0KDH8XYVTxo4QQoixloonAIBhGH6dXXMFFO9sPIs6tRadYwIRqJDh8NVS3PXFv8goqhZyyMQLmAZ2lqdiAeCGRH3G+HgW7VnM4aZiIwMpsCOEEGLEmsAOAG4wTMf+eCDTbBZuz/lCbEvPh1TC4LN7bsDvjw1Bm1A/ZJbU4q4vUnHgSonwgycei5umZxhALmWaPbavYY3n2bxKvkVKa1fKZ+xoKpYQQoiBTsdCo9P3w2pujR0A3DMgAbHBvrhSVIMHfziCWpWGv69ercVbf6YDAB4cmoyuscHoEhuE9U8MRZ+EUFTUqXH/d4fw08Frouy/RVxPadgn1lcmBcM0H9i1CfVDVJACGh2L07m0ZzEAFFdza+woY0cI8TAUCDiPcasEcxuxG4sO9sVPDw1AiJ8cJ7LK8ejPx/ls3xd/X0FWaS1ig33xzNjO/GOighRY/cggTOwVB7WWxRvrz+Dp1WmoVmosfRvSSvD7xDbT6oTDMAyftTtB07EAjIonaI2dZTU1NUhLS0Nubq5Vx5eUlOD8+fOor6938sgIab3+OJGDvu/uoB5WTmK8zqmljB0AdIoJwsoHboSfXIq9F4vwwm8ncaWoGiv+vgIAeOv27ghUyEwe4yuXYtk9N+C1W7tBKmGw8WQe7li6H+fzaWP31qxe3XLRjjF+nd21cmcNyWPUqjSoM0xJU/FEM2bPno2+ffvio48+avY4tVqN2bNnIz4+HuPGjUN0dDS+++47F42SkNZl38VilNWqMX/tKRRU0ocooRm3LmlpnROnb2IYls/sC5mEwZ8n83D3F6lQaXUY2TkKE3rEmn0MwzB4eER7/PqfQYgL8UVGcQ3uXPYvfj2aLcjPQTxPS7tONNbXqICitWfxSwzTsD4ySZMPUu4kqsBu+fLlKCkpQY8ePVo89r///S+2b9+OCxcuIDs7GytWrMDDDz+MEydOuGCkhLQu3D6mFXVqvLj2VKs/oQtNabTrREvrnIyN6hKNj6f1BqB/bXxkEiy4I6XF5+iXFI7NTw/HyM5RUGp0eHHtKTyz+gQq69X2/xDEI3HvPV8rpmIBoGebEMgkDAqrlMiraN0f8kqMpmFt+bt1NtEEdqdPn8a7776LH3/8ERJJy8P6+uuvMXfuXCQnJwMA7r33XnTp0gXffPONk0dKSOtjnFH652IRfjmc5cbReB/u96uwYhq2sTv7tMG7d6YgUCHDq7d0RXJkgFWPCw/wwco5N2L++C6QShhsSMvDLUv24Uhmqc1jIJ5LqbYtY+fn07Bn8fFrrXudXWmNYdcJERVOACIJ7GprazFjxgwsXrwYCQkJLR5//fp15OXlYcCAASa3Dxo0CMePH3fWMAlptbjAo1fbEADAe5vOIbO4xp1D8ipc8URLrU4smTU4GSffuhlzhraz6XESCYMnRnfEr/8ZjIRwP+SW12H6lwfwyfYL0Iho70viPHzxhA3vvRv4AopyJ4zIc3AVsWJqdQKIJLB7+umn0b9/f0yfPt2q40tK9H2YIiIiTG6PiIhAcXGxxccplUpUVlaafBFCWqY2XOQfHNoOg9qHo06txfO/pkGroylZIVjbw645Uon9U0H9ksKw5enhmNy3LXQs8Nnuy5j65QEK3lsBfo2dlVOxgOk6u9aMq4iNFFHhBCCCwG7r1q1Yu3YtHnroIaSlpSEtLQ11dXUoKipCWlqa2cfI5XIA+kDNmFKp5O8z54MPPkBISAj/ZU12kBBiNFUok2DR1N4IUshwPKscX+694uaReQchAjtHBfnK8fG03lh6zw0I8pXhRFY5bvl0H346kElrKr2YcR87a91g1Ki4Ne9DXCrC7cQAEQR2lZWVSE5OxtNPP405c+Zgzpw5yMrKwvbt2zFnzhxotU3fNG3atAHDMLh+/brJ7Xl5ec0Ga6+88goqKir4r+xsqgQjxBrGU4Vtw/zx1h0pAIDFOy7iYkGVO4fmFfjAzo41dkK7vXc8tj07AkM6RKBOrcUbG9Jx/3eHkVde5+6hESeot6GPHScx3B8RAT5QaXU4k9t6Z76Kq7k1djQVa2L69Ol8po776tKlC+677z6kpaVBKtV/isjOzsaFCxcAAIGBgRgwYAC2bNnCP099fT127dqFm266yeL3UigUCA4ONvkihLSscUZpct82GNk5Cmotiw1p1vWcJJYpHVxjJ7Q2oX74+aGBePv27lDIJNh3qRjjl+zF78dy3D00IjBbiycAfdscrp9da25ULMbmxIAIAjtrLViwAJMnT+b//84772DNmjX473//i927d2PatGkICgrCf/7zHzeOkhDvxGfsDBklhmFwa099r7Qjma33xC4U46lusZBIGMwZ2g5bnhmOPgmhqKrXYN5vJ7HzbIG7h0YEZE/xBEAFFEBDHzuqirVCly5d0KZNG5PbEhMT0bVrV/7/N998M7Zs2YJDhw7hlVdeQXR0NPbv34+QkBBXD5cQr8cFHnKjk3//5HAAwMns8la9zkYIYlhjZ0mHqECsfXQw7u6rPyf/QRlar8IVT/i2sJVdY30pYyfaNXbiaZVsZM2aNU1ue/PNN5vcNm7cOIwbN84VQyKkVTO3Bqx9ZADCA3xQWqPCmdxK9EsKc9fwPF5DYGfbxdVVZFIJZg5KwrrjufjnQhFUGp0og1Biu3q1fRm7Xm1DIGGAvIp65FfUIzbEl79vzZEsnM6twPzxXRHiZ7mg0ZOxLNuwxo7anRBCPA3X7sT45M8wDPobgrmj1NTWIUoRFU9Y0qdtKCIDFahWanAwo8TdwyECUdq5DCBAIUPXWEOjYkPWjmVZfLjtPF76/TR+PpiFuT8cQZ3KO7P5tSot/7ujqVhCiMfhp2IbBR43GqZjaZ2dY1R2rnNyJYmEwdhu0QCAnedonZ234IsnbJyKBYzX2ZVBp2Px+vozWP63vgWSr1yCI5lleOKX4/wHQ2/Cra9TyCTw9xFXpl28ZxFCrHQ+vxLb0/PdPQyvZmlnhP7J+ozdsWul0FGzYrs5uvOEq4zrHgMA2Hm2gHrbeQl7iyeAhnV2hzPL8NyvafjfoSwwDPD+XT3x44MDoZBJsPt8Ieb/dtLrzg8lhu3EIgMVotonFqDAjniBZ1al4ZGfjiEtu9zdQ/FKOh0LtVZ/Um4ceKTEh8BXLkFZrRoZxdXuGJ5XEFMfu+YM7RgJP7kUeRX1SM9rvf3LvEnDzhP2Z+xOZpdjQ1oeZBIGn864AfcOTMSAduFYPrMvZBIG69Py8M6ms171YUCshRMABXbEC5QY/sAoa+ccal3DNErjwM5HJkGfhFAANB3rCDFXxRrzlUsxvFMkAGAHtT3xCvYWTwBAu8gAhPrL+cd/fX9/3NE7nr9/TNcYLJraGwDwfWomPt11SYARi4NYW50AFNgRL8Ct39h9vtDNI/FOXNABmM8oNayzowIKe4mtQXFzxnLTsRbW2WUW1+DXI9nUAsdD2Fs8AegLqGYOTEKbUD/8+OAAjO4a3eSYSTe0wQLDTjVLdl7Cu5vOesUe0yUiztiJst0JIbbgArvz+VXIKatF2zB/N4/Iu7QU2HH97I5Sxs5uYmxQbMlNXaPBMEB6XiVyy+vQJtSPv6+8VoWpXx5AUZUSO88V4PP7+jYpuCHi0hDY2VcA8ML4LnhhfJdmj5k9JBl1ai0Wbj2Pb/dfxbWSWnw6ow8CFJ4bgpRUN6yxExv6iyMez7jiag9l7QTHLeyXSRhIJE0XCfdNDIWEAbJKa1FQWe/q4XkFT5mKBfT7YvYzLJrf1Shr987Gsyiq0l/wtp8twHNr0rwiO+PNuMyqrw17xdrj0ZEdsPSeG+Ajk2DnuQJM+/KAyfmiolaNNUeyMOvbQ5j7wxGczxf3Gk5aY0eIkxgv7AeAXRTYCU6tMV84wQnylaNbnL6fFWXt7ONJgR3QUB1rvM5u59kCrDuRCwkDzBvXGXIpg02nrmP+Wu+riPQm9XbsFWuv23vHY9XDgxAR4IP0vErcuexf/HIoC4/8eBQ3/ncnXvr9NPZdKsbOc4W4fel+LN11SbStUoopsCPEOYwX9gNA6pUS1Ko0bhqNd1Jp9Z/om5tSo3V2jvGEBsXGuMDuYEYJKuvVqKhV49U/TgMAHh7eHk/d1AlL7+kLqYTBuuO5eG39aa+qiPQmfLsTJ2fsOP2SwvDH40PRMToQ+ZX1ePWP09h+tgAqrQ5dYoIwf3wXjO0WA7WWxcc7LuKuL/7Fueviy96V8u1OKLAjRFDG2broIAVUGh32Xyp244i8j9KKbBLXz+7oNQrs7OFJa+wAoH1UINpHBUCtZbH3YhEWbExHYZUS7aMC8Ny4zgCACT1isWR6H0gYYNXhbCzY6F3tLrwF36DYhe+9xAh//P7YEIztFoPkCH88NqoDtj07HH89NwJPjO6Ir+/vhyXT+yDET44zuZW4Y9l+rPjnisvGZw2uKjZcZNuJARTYEQ9nvLB/fEosAKqOFRrfw66ZbFL/JH3G7mxeJaqVlDG1lac0KDbGZe0+2X6Rn4JdNLW3yWbyt/eOx0dTeoNh9O0uHv35GCrr1e4aMjGD++Dma0cfO0eE+Mnxzez++Hv+aLw0oSu/PRmgr7addEMb7HhuBJ+9W7j1PPZcEMe5nWVZvio2gqZiCREWt/5CKmH4C83u84W0pkdA1mSTYkN8kRDuBx2r316I2MbT1tgBwLhu+r+3jOIaAMDc4e35nQiMTe7XFh9N6Q0fqQR/pRfgzmX/4kJ+lUvHSixzpI+ds0UH++Lr+/thzpBkAMBbG9L58bpTjUrL/81SHztCBNawhymDge3D4e8jRWGVkrriC8jSPrGN3ZhE+8baq2HnCXHtOdmcGxLD+GxF+6gAPG+YgjVnSr+2+O3RwYgP8cXV4hpM+vxfbEjLddVQiQUsyzrc7sTZGIbBC+O7ICZYgazSWn4vWnfiWp34yaXw9xFfyxYK7IhH4zJ2cqkECllDV/xd56krvlC44omWskkN/exonZ2tPKlBMUcqYfDQ8HaIDfbFJ9P6tDiV1zshFJueHo5hHSNRp9bimdVpePvPdGpk7EYqo4pTVxVP2CNQIcObt+mbHC//5woyDVlidxFzc2KAAjvi4bgTEzeNcFPXhulYIgxVC+1OODcaCihOZJWLtkWBWHla8QTn8VEdcfDVm/ht5VoSHuCDHx4cgCdGdwCgX3d3x9J/cSa3womjJJYojdYo+4o0Y8e5tWcshneKhEqjw5t/pru1EIcrnBBjRSxAgR3xcFyPNW6acFTXKADAqZwKFFKzXEGotA3T3c3pEBWIUH856tRanKWpcJuoNNZlRb2BVMJg/viu+Ob+/ogM9MGFgipM+vxffCbinmXeiluvxjAt/327G8MwWHBHCnykEuy9WIRtZ9y3NzjX6oQydoQ4gUpruv4rOsgXvQ3ZA7FUUHm6hoX9zX+il0gY9E/SZ+2on51tPLEq1lFju8fgr2dHYEJKLDQ6Fp/suIjJy1NxqYAKK1zFuNUJw4g7sAP0bXYeHdkeAPDOprOocVMFfrEhYxchwu3EAArsiIdTm7kg3mTYiHrXOQrshKCyoXluf2pUbBdbfsfeJCJQgeUz++LTGX0Q7CvDqZwKTFy6H1/8fRkayt45ndgLJ8x5fHRHJIT74XpFPd7bfA7FhkIGVyoVcasTgAI74uHMVWyOMQR2+y4Vi6I03tOptdav/+LW2R3NLKNmtDZQeugaOyEwDIM7+7TB9udGYlSXKKg0OvzftguY9MW/NKXvZPyuEx70vvOVS/H27fpCilWHs9D/vZ24efE/eGvDGWw9fR1VLuiTyFXF0lQsIU7AZ+yM1oekxAcjOkiBOrUWadnlbhqZ9zBuKdOSHm1CoJBJUFKj4vubkZZ5Yh87ocWG+GLlnBuxaGpvkx0HPtl+gSpnncRdzYkddVO3GLx9e3d0jQ0CAFwsqMYPB67hsf8dx+hF/zj9AwHfnJimYgkRnlrbNGPHMAy/d+mxa9RTzVG2rP9SyKT8Gkdqe2I9Cuz0GIbBlH5tseP5hrV3n+2+jNs+249jtF2d4MTcnLglc4a2w7ZnR+D4G+OwYmZfzB6chDahfiiuVmLGVwec+n7hqmJpKpYQJ7C0j2m/JG5KkC4GjrI16OCmY6lRsXV0OhYaXcvbtrUm0UG+WDGrH764ry8iA31wqbAaU1YcwBvrz9CWZALilwCIuIddS8IDfDChRxwW3NkDW54Zjv5JYais12DmN4ex92KRU74nv8aO2p0QIjxuH9PGuyJwm9Ifu1ZG24s5iM/YWbkrAjUqto1xk9jWnrFr7Naecdj5/EhM7dcWLAv8dPAaxn3yD/5Kd1+rC2/SUBXrWVOxloT4yfHTQwMxsnMU6tRaPPTDEWw5fV3Q78GyLB/Y0Ro7QpzA3FQsAHSLC4afXIrKeg0uF1W7Y2heg19jJ7OuHUK/pDAwDJBZUovCKuol2BLjJrEU2DUV6u+Dj6b2xi9zByI5wh8FlUr856djePKX49T3zkHc2kVfD87YNebnI8XX9/fHxJ5xUGtZPPnLcaw6nCXY81cpNfyHsYgAWmNHiOAa2p2YBh1yqYTvhn+UpgQdwu+KYOU0YbCvHF1jgwHQ794aKuPAjqZiLRrSMRLbnh2BJ0Z3gEzCYNOp63h/yzl3D8ujeVvGjuMjk+Cze27AjBsToGOBV9adxmt/nBakCKfUsL7O30cKPx9x/t7oLEI8WnP9v7jp2KO06Noh5noFtqRhnR397ltiXJziCU1i3clXLsX88V3xxX19AQAr/83EHydy3Dwqz+WJ7U6sJZUw+ODunnhubGcwDPC/Q1mYuuIAsktrHXreEsOuE2JdXwdQYEc8XOOdJ4z1NRRQHKfKWIeY6xXYEmpUbD1bM6IEuDklFk+N6QhAn41Jz6O9Zu3h7f0TGYbBM2M7YeWcGxHqL8epnArcvmw//nZgVyKuIjZcpNOwAAV2xMPxe8WaOTH1TWxY61VU5fru5N5C6UDG7mxeJardtO2Pp1C2on1ihfTs2M4Y1SUK9WodHv35GMprVe4eksfx1D52thrVJRqbnhqGXm1DUF6rxgPfH8HH2y/YtbtJich3nQAosCMeTqU1XBTNZDtC/OToHK1vYEn97OxnT4+1uBA/tA3zg44FTmTR77451MPOPlIJg0+n34DEcH9kl9bh6dVp0FIFvE08uY+drdqG+eO3Rwdj5qBEsCywdPdlTP/qoM1Ts2LfTgygwI54OK7diaWLYj++7QlNCdqrYXcP204XN/LTsRTYNYcCO/uF+Mvx5ax+8JVLsPdiET7ZccHdQ/IoDX3svDtjx1HIpHhvUk98OqMPghQyHLtWhls/3YcNablWPwe3N204rbEjxDla2u6qP9eomDJ2drM38OADu6sUVDenuQIg0rJuccH4cHIvAMDyv6+4ZVN4T6VsRRk7Y3f2aYMtzwxHv6QwVCk1eGZ1Gp5fk2bVPrNcxi6S1tgR4hyW+thx+ifpg4szuRX8tAOxjb2BB7fO7kR2GfUba4Y9axiJqTv7tEGXmCDoWGqMbQtvL55oTkK4P9Y8MgjP3NQJEgZYdyIXE5bsw/5Lxc0+TuzNiQEK7IiHaymblBDuh6ggBdRaFqdyqHLOHva0OwGADlGBCPWXo16tQ7qTN+X2ZDQVK4wb29FWdrbiPux6e/GEJTKpBM+N64w1/xmMtmF+yC2vw8xvD+GltadQUWc+e1dcLe7txAAK7IiHa2n9F8Mw/HQsFVDYx9J+vC2RSBp+95RFsUzVirMmQrqRWuzYrDVn7IzdmByOv54dgdmDkwAAa45m4+bF/2Dn2YImx5ZyfexoKpYQ57C0V6yxfklUQOGI5noFtoS72B6mdXYWNWTsWmfWRCjcey09rxI11GLHKg2BHb33AhQyLLizB379z2C0iwxAQaUSc388iqdXnUCJYd2m8T6xlLEjxElUVkwT9jPK2LEstUOwlSNThVyj4qP0u7dIZWfVMTEVH+qHNqF+0OpYnMgqd/dwPAK/84QX7RXrqAHtwrH1meH4z4j2kDDAnyfzMPaTf7DueA4q6zR8MoHW2BHiJNbsipASHwKFTIKyWjWuFNW4amhew952JwDQs43+d19ao0JGMf3uzWmtlYnOwBXsHKbpWKt4616xjvKVS/HKrd2w/omh6BYXjLJaNZ7/9SRmfXcIABCokIl6XSKdSYhHa6iKtbzHpo9Mgt4JoQBoOtYejmTsfGQS9G4bCgA4mV0u4Ki8hzVZZ2KdG9sZMsQU2FmlnjJ2zerVNhR/PjkUL07oAoVMwhfgiTlbB1BgRzyctdmkhkX8VEBhK0f7rHWMCQQAZFC21CzqYyccbp3diaxyarFjhYaMHb33LJFLJXh8VEdse3YEBrXXv7/aRQa4eVTNk7l7AIQ4wtpsUv9kqoy1l6MZpfaGk+BVmoo1i9qdCKejocVOea0a6XmV6GPI1BPzqHjCeu0iA7Dq4UE4dLUUnaID3T2cZtGZhHg0lRVVsQDQN1Ef2GUU11BnehuwLNvitm0taR+lD+yuFFULNi5vQg2KhaNvsUM7nliLK57wpalYqzAMg0HtIxARKN5WJwAFdsTDtbTzBCfU3wcp8cEAgL0Xi5w+Lm+hMprOsqfdCQC0j9R/us0sqYGONmlvgjJ2wqICCuvVU/GEV6IzCfFotuyKMLpLNABg9/lCp47Jm3BBB2D/Opy2YX6QSRjUq3XIr6wXamheg9bYCcu4gIJa7DSPb3dCHyq8Cr2axKPZclEc3VUf2O29WAQNLay2CjcNC9gfeMikEiRG+AOgAgpz+J0naDpMED3iQ+Ar59ob0fS/JSzLNqyxo/eeV6FXk3g0fipWZrndCadPQijC/OWorNfgODUwtQoXdMgkDCSSln/HlnDTsVeL6ULbGDUoFpaPTMIXTRy+SsVSlqi1LLiEpph7shHb0ZmEeDRrGhRzpBIGIztHAaDpWGvZ8vttTkMBBWXsGqO9YoXHtT2hfnaWcT3sAHrveRt6NYlHszXbwU3H7qHAzioqrf7k7+jCfmp5YpmSiicEx+9RTIGdRVwPO4Cyxd6GXk3i0WxtxTGycxQkDHChoAq55XXOHJpXUGkca3XCaUeBnUVUFSu8vklhkDBATlkdrlfQ37k5xoUTDGP/MgsiPnQmIR5Lq2Oh1VnXx44T6u/D97SjrF3LhFr/1c4wFZtTVstfUIheQwEQrXMSSqBChu6G9kZHaLcZs5S0BMBr0StKPJbxlkG2ZDtoOtZ6QmWTogIVCFLIoGOBrJJaIYbmNahBsXNw07HUqNi8ejXXnJg+UHgbOpMQj2XaPNf6qQSun92/V4r5kxsxT6geawzD8Fk7KqAwRVOxzsEHdrTOzixqdeK96BUlHktt1DxXLrH+rdwtLgixwb6oV+twMKPEGUPzGrY0gG4JFVCYpzJMTdMCdmFxgd2FgiqU0DaCTShp1wmvRWcS4rG4wglbe6wxDIPRXfVtT2g6tnlKvt2J44ur21EvO7O4zDNlToQVFaRA97hgsCzw9wXaRrAx2nXCe9ErSjyWI9kkbjp2z4Ui2naoGSohM3aGqVjafcIUbSnmPDd1o20ELeE+tNEaO+9DZxLisZQONM8d2jESPlIJskprac1XMxrWfzl+8qeWJ+ZRg2LnGWO0jaDxvsekoXiC3nfeh15R4rH47cTsCOwCFDIMbK9fg0PTsZapBdzuigvsSmpUqKhVO/x83oIaFDtP77ahiAz0QZVSQ7tQNELtTrwXvaLEYzUEHfat/2qYjqXAzpKGjJ3ja+wCFDLEBvsCADJonR2PqmKdRyJh+L/zXfQBzkRDYEdTsd6GziSkRdVKDTaezEONUuPuoZhw9ILI9bM7fLUU5bUqwcblTYRe/0XTsaZ0OhYaQ5NtWmPnHLTOzjwl38eO3nfehl5R0qLv/72Kp1adwDf7rrp7KCZUDkzFAvogo1tcMDQ6Fn+cyBVyaF5DyOIJgAooGlPZ2WSbWG9YpyjIpQyuFtfgShFlijmUsfNeMncPAAB0Oh3Wrl2Lv/76C9XV1UhJScEjjzyC2NhYi49ZunQpNm/ebHJbcnIyVqxY4ezhtjrF1fps1qmccvcOpBGu3Ym9gR0A3DMgAW9uSMfqw9mYMySZ9kxsROhpQsrYmVJqKLBztkCFDIPaR2DfpWLsPleIDlGB7h6SKHAZO2qz431EEdjNnj0bUqkUN910E2QyGb799lusWLECx48ftxjcpaeno6amBq+99hp/W1BQkKuG3KpwWYULBVVuHokptQBBx5192uD9LedwoaAKJ7LL+X1kiZ6jWdHG2vO7T1DmBIBJpSZNxTrPmK7R2HepGLvOF+DhEe3dPRxRoOIJ7yWKwG7p0qUIDQ3l/z9p0iQEBwfjr7/+wuzZsy0+LiYmBhMmTHDBCFs3LoDKKatDjVKDAIUo3jaCbFAf4ifHxJ7x+P14DlYdyqLArhGhM3btDU2KM0tqoNOxNjWW9kbGU92ULXaeMV2jsWDjWRzJLENFnRohfnJ3D8ntqI+d9xJFqG4c1AHA2bNnodFo0Llz52Yfl5aWhrvvvhsPPPAAvvvuO+h01KfIGbjF3QBwqVA8mRa+3YmDFZv3DEgAAGw6dR1V9dSGwxj3O1YIlE1qG+YHuZRBvVqH/Mp6QZ7Tk/E97Chb51RJEQHoGB0IrY7F3ou0CwVAfey8mWhe0WPHjmHChAkYMmQIbrnlFqxZswaDBw+2eLyPjw/Gjx+PGTNmoFevXnj99ddxyy23NLuLgFKpRGVlpckXaZnxAu+LIpqOVTnQoNhYv6QwdIwORJ1aiw1peUIMzWsI9TvmyKQSJIb7A6ACCoBanbiSJ1TH1ig1WLb7ErJKap3+vah4wnuJ5mySlJSEZ599Fo8//jh69uyJN998E/n5+RaPf//99/H5559j2rRpeO6557Bjxw7s3LkT69ats/iYDz74ACEhIfxXQkKCM34Ur6M2Wgd0MV9EgZ1AzXMZhsE9AxIBAKsOZzk8Lm/ijMDDE/aMXfnvVax3QaU0BXauc1PXGAD6vpVanTi3EVyflotF2y9i7o9HoNE6dwaK3yuWiie8jmhe0cjISEyYMAEzZ87Etm3boFQqsXjxYovHBwaaVjalpKQgKSkJx48ft/iYV155BRUVFfxXdna2YOP3ZmrjjJ2YpmK5bJIAF8W7b2gDH6kE6XmVOJ1T4fDzeQulwO1OAOMCCnFm7HLL67Bg41k8/2saiqqUTv1e3MWVAjvn65sYihA/Ocpr1TiRVebu4ZhVUKFfnnCxoNrpHzKVaiqe8FaifEXlcjnatGmD69evW/0YnU6H8vJy+Pj4WDxGoVAgODjY5Iu0jGsrAogrY8eNS4hqwrAAH0zooa/AXnWEsnYcISqPG2sv8pYneeV1AAAdC2w9Y/05yB5CN4AmlsmkEozqEgUA2HlOnNOxFXUNa3w/2XHRqVvv1Wu4BsU0Fett3H42qa+vx7fffmuyNm7v3r04cuQIxowZw9+2ZMkSPProowAAtVqNL7/8ki+WYFkW7777LioqKjBp0iSXjr81MM7Y5VfWm5x83EmoqVjODEMRxZ9p4ttlw12EbncCiL+XXYFRUcfGk85dc+mMjCixbIxht5ld5wrcPBLzyo3OrWW1any665LTvhdl7LyX219RuVyOEydOoG3bthgxYgR69eqF2267DS+99JJJq5MzZ85g//79AACpVIozZ86gbdu2GD58ONq3b48vv/wSq1atQu/evd31o3gtdaO1HpdEUkDBL+wXYB9TABjcPgLJEf6oVmqw6RQVUQBGVZuCTsXql1HklNXyU5FiUlDZMP16JLOMz+A5A62xc61RnaMhlzK4VFiNM7niW3JRbsjQ3dknHgDw44FMXHbS8hcqnvBebj+bSKVSLFu2DGfPnsUHH3yAH3/8EdevX8c777xj0tfpueeew5dffgkAkEgkWLp0Kc6ePYuFCxdi8+bNyMzMxLRp09z1Y3g1bspTZug5drFAHOvs1AJnkxiGwQy+iILWXwINv2MhpwojA30QpJBBx8Il1X+2KmzUhsWZQT5NxbpWiL8cN6fol1z8elR8f+Ncxu62XvG4qWs0NDoW/9181infiy+eoA8VXkc0r2hISAiGDh2KPn36ICAgoMn9KSkpGDp0qMltoaGhGDp0KLp3797s2jriGO7i3jlGv7OHWFqeOCPomNy3LRgGSMsuR0m1cxfOewJnZJQYhkH7aH3W7kye+LIm3FRscoS+LcvGk85bZ0cZO9e750b9h7c/TuSiTiWujHFFrX77xlB/OV6b2A1yKYM9F4rw9wXh1wTyGTtaY+d16GxCWsSts0qJ1xebiCewMxRPCHhRjApS8H3WLoioUMRdlAL3seMM7RABANh9vvlmsZ/tuoRZ3x5y6QWYm4q9f3AypBIGp3MrnLYekPvboukw1xnSIQJtw/xQVa9xenGMrbiMXaifHO2jAjF7cDIA4L3N55osiXEUNSj2XvSKkhZpDAFUjzYhAMQT2Dkr6OhiyEyep8DOZMsrIXHNYv+5UGjxglVeq8Jnuy5h36ViHMgoFvT7N6egSp+x6xYXjKEdIwEAm5xUROGMNYykeRIJg+n99YVSq0W05EKnY/nCtBB//ZZnT93UCeEBPrhcWI3VR4Qda8OWYvTe8zb0ioqEUqNtdtcMd+IuvN3i9Bm74mqVKKYphV5jx+kSqw/sKGNnNN0tcODRJyEM4QE+qKzX4Ng18z3Ftp8t4Lezu+TCdZ2FhoxdTLACt/eKAwBsdNI6O5qKdY+p/RMgYYDDmaW4UiSONcNV9RpwlwBuL9sQPzkeH9UBgPAfLhqqYilb7G3obCICpTUqDPjvLjy9Os3dQzGLu7gH+8n4aUoxFFA4K+jgArvzIslMupOzFvdLJQzfU8xS64mtpxumyVz1fqtRalBtaHUTHeyLm1Ni4SOV4GJBtVMCfb5BMRVPuFRsiC9Gd9FnjX8VOBNmr/I6/fo6fx+pSbB1Uzf9jhknssoFW5LAsiztPOHF6BUVgStF1aioUyP1suumm2xhvF9o5xj9oncxTMc2BB3CtDvhdDUEdpcKqqAT6dZDruLMjBK3xdMuM3t3VtSpsd/o7+FSoWveb4WGnSYCFTIEKmQI8ZNjpCEAdUZPO8rYuc/0G/XTsWuP5fCvgztVGK2vM5Yc4Y/4EF+otDocvVYqyPdSa1lwpzbK2HkfOpuIAJd5KqtViTKQMN7hQUyVsc6aik2OCICPTIJalRY5Zc7rYeYJnNmOY0TnSMgkDDKKapoUJ+w4WwC1lkWoYa3RpYJql/xtcBWx0cEK/rbbe+t7im08lSf4cglqUOw+Y7pGIzpIgZIalSgaFnM97IIbBXYMw2BwB/1az9QrJYJ8L+P+kbS+0/vQKyoCXOCkY007j4uFcQAlpsBOZfi9CR3YyaQSdDQ00T2fXynoc3saZ1Qec4J85RjYPhxA0+nYLYZp2PsHJcFHKkGdWotcJzYK5vCBXVBDYDe2WzT85FJcK6nFaYGb2lLGzn1kUgmm9GsLAFglgulYviLWX97kviGGKnKhZnWURhlKCuy8j0OvqFarRVpaGjZt2oRNmzbh5MmT/DZfxHoao6pAMRQlGGNZll/ALpcyRoFddZPshVKjReqVYmhdlHV0xj6mnK5UQAGWZZ2ypZixMYbp2N1G07EVdWrsu6Rvg3J773i0j9L3tXTFdGxD4YQvf5u/j4yv4hV6OpYaFLsXNx2771IRcsrc2yyb72Hn17Qn65CO+sDudG6FIFs6Ko2qsY03AiDewa6zSXp6Oh555BFERETghhtuwO23347bb78dffr0QUREBB599FGcPeucbtneyLjdQ0mNyo0jaYrL2ACAXCZB+6gASBj9xZdbj8R58pcTuPfrQ/jjRK5LxubMoIMKKBp+v4DzMkpjDQHT4aulqKzXX7B2GqZhO0YHolNMEDoZfZhwNi5jZxzYAQ3TsVtO5ws6HUsZO/dKigjAkA4RYFngt6M5bh0LNxVrLmMXF+KH9pEB0LHAoQzHp2Oph513s/lVfeyxxzB48GDU19fjq6++QkZGBmpra1FTU4OMjAysWLECNTU1GDRoEB5//HFnjNnrGAdPJdViC+waLu5yiQS+cimSDZu4G0/Hbk/Px46z+um0tGzz7SucNTYfgfaKNdaZMnYm70tnXQCSIgLQISoAGh2LvRf1WTpuGvbWnvpWI52jXVewU2D4sGI8FQsAIzpFQSZhkFteJ+iUcEODYrrAuguXtVtzJNutexeXN+ph1xiXtRNinR3f6oR2nfBKNp9NgoODkZmZiR9//BHTpk1Du3bt4OfnB39/f7Rr1w7Tp0/HTz/9hMzMTAQFBTljzF5HYzR9XVojrqlYk8DOUH3aOdo06KlVabBgY0OGNqPIOV36G2uYxhL+5MRNxV4trhHlRvWuYFwp6KypWKChncPuc4WorFdj3yX9OqKJhsCuk6ES21mboRuzlLHz85HyO69Y6rtnD2pQ7H4TesQiJliB/Mp6/OrGrB2fsTMzFQsAQwwFFAeECOxon1ivZvOr+uGHHyI8PLzF48LDw/Hhhx/aNajWRq0xytiJbCqWyygwjL73GNCQzeKaxn626zJyy+v4DuYuC+z4qVjhM3axwb4I9pVBq2NxpdA1P4/YcEGHVMLwr70z3NRVPx2750IhtqcXQKXVoUNUAN9ah5uKdUVlbKGFwA4A+iaFAQCOOyGwo6lY91HIpHh8VEcAwBd7Lrvtg1xFXcM+seYMaq/P2F0oqEJRlWMJACV9oPBqTnlV6+vr8csvvzjjqb2SWmdcPCGuwE5jVHnKLbLlLrgXCqpwsaAK3+zLAAD8d1JPAEB+ZT1qDE1enYmv1nXCyYlhGHSN1WdoLhS0zspYVy3s75cUhmBfGcpq1Vi84yIAfbaOe78lhfu7pDKWZVl+n9iYYEWT+/sZArtjWQIGdtTuRBSm35iA2GBfXK+oxxo3Vcg2ZOzMB3bhAT7obtj954CD6+y4NXa+NBXrlQQ9mxw9ehSPP/444uLiMHv2bCGf2qupNcZTseIK7Ph1bEYX9y4xDQ18X19/Bhodi3HdYzC5X1uEB+inEZy1abrJ2DQN/fWcgS+gaKXr7FwVdMikEowy7ALABW63Grby4u7nKmOduc6uSqlBneGCFx3UNGPHBXbnrlcJ9sGFW+vkjOUExHq+cimeGK3fuuvzPZf5wMeVGu8Ta45QbU8oY+fdHH5Vi4uLsWTJEvTq1Qs33ngjCgsL8dlnn6GwsGk3eWKexmh6qVhk7U64wE5mNN2ZHBkAuZRBjUqLw1dL4SeX4u07UgAA7Q2FFa7Yf9FZW4pxxLhnbH5FPSZ+tg9rjzl/LZDxjiPOxrUTAYD2UQH8hwcOPx3rxHV23DRssK8Mfj5NA624ED/Eh/hCq2NxMrtckO9JDYrFY9qNCYgP8UVBpRKrD2e5/PvzxRMWMnaAcAUUDYEdfaDwRlafTc6fP4/p06fjscceQ3FxMbZs2YIpU6agTZs2WLFiBWbOnAkAWLt2LWbNmoWwsDCnDdrbGLeVEFvGTqVp2gRYLpWgfWQg//9nxnZCm1A/AOAzK65YZ+fswEOMgd1f6flIz6vE8r8vO/17ubJic2TnKH4dn/E0LMcVlbEFZnrYNcatsxOqgILW2ImHQibFE2P0a+0+//uKS7N2LMuigm93Yr54AgAGtIuAVMIgq7QW2aX2991Tcu1OaJ9Yr2T1qzplyhRMnjwZo0ePRlxcHKZOnYrAwEDs3LkT58+fx4svvujMcXo1jVFbCbEFduamYoGGSsVO0YF4aFg7/vb2hh0bXDEV68ziCQB8M+brFfVNmoJqdSze3XQWX+/NcMr3tiTfkFW6UlTj9OyuK4OOUH8fTOwZh0CFDJP7tm1yv3EBhbNYqog1JvQ6O5VhoT41KBaHqf0S0CbUD0VVSvzvkOuydnVqLX8+s7TGDtDvYdy7bQgAx6pjuYydL2XsvJLVZ5P8/HwMGjQIAwYMgEajQe/evTFhwgQMGDDAmeNrFYx3niitVbls5wZrcK1YGgdPDwxNxvBOkVgyo49Jxoybis0oduFUrJMuiiF+csSH6C/yjTNFm09fx7f7r+K/W84h0wVBLKegop7/9+GrwmwIbomzf7+NLZ7eB0dfH8v3STRm3PLEWZWxXMYu2kzhBKd/kr4jwPFrZYKMg4onxMVHJsGThqzd8r+voE7lmqwdVzghlzLwN7MMwNgQft9Y+9fZ1VPGzqtZ/ap++umnGDVqFO666y6sWbMGAwcOxNNPP424uDg89dRTOHHihDPH6dVURhk7lgXKa8WTtTM3FQsA/ZLC8dNDA5ESH2JyO5+xK6oRfMN0Y1odC+666syLorkCCpZl8cWehqnQVS5cj3PdhYEdP9XthAbQ5kgljMUqPVdUxlqTsesaFwQ/uRSV9RpB1pFSHzvxmdKvLdqG+aG4Won/Hbrmku/JBXYhfj4tbvFlvM7O3nMsFU94N6tf1fvuuw8ZGRk4ceIEpk2bhsWLFyM3Nxdff/01MjIycOONNwIAPv/8c+TkuHdrFk9jnLEDxNXLTm3jtl2J4f6QSvSFFVwGxBlc1Ty3C9fyJL+h5cmuc4U4n18F7vz761HXdazngg8AOCjA1kLNUYpoH1NXVMYWVhkCuyDLGTu5VILeCfoPM0Kss6PATnzkUgmeMmTtVvyT4ZK1duUt9LAz1jcxDD4yCQqrlHZ/uKDiCe/m0NlELpdj8uTJ2Lx5M7KysvD+++/j008/RUJCAh/okZapGwd2Iuplp7ZxHZuPTIKEMH0hRYYTK2NVWtcEdl0bFVCwLItlhmzd3GHtEB/ii7JaNbaeznfaGDgsy5pk7C4UVDk1u+vsqmNbOXvPWGuKJ4CGdXZHBQzsxPI7Jnp3922LNqH6rJ0rKtArWuhhZ8xXLkV/w3tw1zn7uk9wH0R9aSrWKwn2qsbHx+OVV17BxYsXsXfvXvTo0UOop/Z66kZrdcRUQGFrxg5omI694sS1Z+a2OnMGroDiQn4VWJbFgYwSpGWXw0cmwcMj2mPGgEQAcMmUTWV9Q5+1NqF+YFngSKbz9uVtCDrE8ameq4y9VOicjB2XDY22MrATYgcKWmMnTnKpBA8P1xeFfbU3o8msitC44ixrMnYAMNawDd9Hf13g91a2Bb9XrEj+tomwbD6bnD17tsVjhg8fjpUrV1p1LDFtUAwAJSLaL1alNb/Grjl8AYUzM3ZG04QtrUlxRIfoAEglDCrrNcivrMcXe64AAGbcmIDoIF9MvzEBUgmDI5llTm+LwgUeof5yjOisX0B9+KrzpmP5oMOJgbMtnFkZy7IsCpvZdcJY30R9YJdRXOPQhzCdjoVa69wm28R+029MRHiAD7JKa7HljHMz8g097Cy3OjF2/+Ak3NknHhodi6dWncCGtFybvh/tFevdbH5Vx40bh1mzZuHff/81u3BTp9Ph77//xn333YexY8cKMkhvp2mUsRPTVKzGjm27XNHyxNYpYnspZFI+UF1zJBv7LxdDJmHwyIj2APTTdjd31396dnbWjpuGjQ32xcB2+gXUh5xYQCG2acLOTqyMLa9V84FsVDNr7AB9a5aOhuyhI1k74+UEYvkdkwZ+PlLMGZIMQF8h68xisIbiCesydjKpBJ9M64Mp/dpCq2Px7Jo0/HrU+q3Q+IwdTcV6JZtf1XPnziE+Ph633norIiMjMW7cOMycORP33XcfbrrpJoSHh2PSpElo27Ytzp8/74wxex0uSAn2lQEQV8ZObUfWxhVNip25T2xjXGXs54a1dXf2aYO2Yf78/fcNTAIArDue69Q9crlWJ7EhvhjQTt9240xuBaqd9D1d3e6kJYlGlbE5ZcJWxhYYCifCA3ysmp7ql+h4PzulhgI7sbt/cBL8faQ4d70S/1wsctr3qbCheIIjlTD4v8m9cO/ARLAs8OLaU1Z/uOT72NFesV7J5rNJcHAwPvzwQ+Tm5mL58uXo3r07amtrUV9fjx49euDrr79Gbm4uPvzwQwQHBztjzF6Hu4DGGnqmiWmNHTcVK5PYkrHTB3Y5ZbVOqxZVOXmfWGNcAYVay4JhgMdGdTC5f0iHCCRH+KNaqcHGk3lOGweXsYsL8UV8qB8Swv2gY4Gjmc7J2ilFlrEzrowVep0d38OuhWwdp58AO1AYV3aLJXgmpkL9fXCvYR3t8r+vOO37lNfatsaOI5Ew+O+kHnxm8bU/zmDbmZbX3PF97ETyt02EZferGhgYiGnTpuHTTz/FunXr8Pvvv+PTTz/F1KlTERDQtMEosYzbeYKrxhPTVKxaY3tmLCpQgSCFDDoWuFZi/7Y3zVHZUdRhL67lCQDc0iOWn4bjSCQMn7X7+dA1p03Z5DfqszYgWT8d66x+dq7cK9ZanZ1UGWtNDztj3NZiJ7PLTQI0W6i0rlknShzz0PB2kEsZHLpaKthWco3ZOhVrjGEYvHV7d8wcpA9Av91/tcXHULsT7yaeM3Yrxp3g+cBORBk7e9ayMQxjNB3rnAIKV7bi4DJ2APD4qI5mj5nSry18ZBKcya3EqZwKp4yDCz5iDe+Tge3107HOWmcnxorNTlxlrMC97Ar5wM66jF37yACE+suh1Ohw9nplyw8wQ2xrGIl5cSF+uOuGNgCcl7Urr2t5n9jmMAyDJ0d3AsPoK+Vb2keWiie8G72qIsBl7LgLtpimYrnCDluniviWJ05aZ6d2YfPchHB/vHprVyy4IwU92oSYPSYswAe39YwD4LwiiutGa+wAYKBhnd2pnHKnbH2kFmHgwfeyc9JUrLUZO4mE4atj7c3iUHNiz/HIiA5gGGDnuQKnNMiuMPSjtKaPnSWxIb4Y0kGfxV9/ovkqWT5jR8UTXoleVRHg9mONMVywy0S0X6y903HtIp1bQKHkiydcM4X1yIgOmG1Yx2LJfYapkPVpeXwGSEh8xs7wPkkM90dssC/UWhYnBNqU3phKZMUTgPMqY63tYWfM0X52lLHzHB2jAzG+eywAYIUTsnblNvaxs+SuG9oCAP44kdvskhBujZ0vTcV6JTqjiABXoMAt3GZZfXAnBvY0KAYaCiiuFjtpKlaE67/6JoahX1IYVBodvrFinYst6tVaPpMbF6zf2YNhGKdOx6pEtKUYh6uMrVfrcC7fvilQcwqqDBk7K4sngIbAbu/FIrsCeZVWf3GlwM4zcEVTf57ME3S/YpVGh1pDxj3Uyj52lkzoEQtfuQQZxTXNLgmhjJ13c+hVValU2LNnD///gwcPYs6cOXj33XehVqsdHlxrwfWK85VLEWb4xCaW6Vh7+8W1j9RnVjKc1MtObUfjZGdjGAZPGvaY/PngNUFfQ655rq9cgmA/GX871/bkkBMaFYsxoySTSjC6axQA4P0t5wQrVCm0sXgCAPonhaFHm2BUKTV4ff0Zm8fC9RITU+BMLOudEIrB7SOg0bH4Zl+GYM/L7TrBMECQr6yFo5sXqJDhZkNm8Y9mpmNp5wnv5tAZ5b333sOBAwcAANXV1bjttttQUFCA77//Hq+//rogA2wN+OBJwiA8QP+JrbhaHL3s7A2guKnY8lq1U4JU7ncmtvVJozpHoUebYNSqtFj5r3BZu3yjwgnjCkquUfGJrHLBW8uIsXgCAF67tTt8ZBL8e7kEWwTYo1enY1FYZdsaO0AfZP7f5N6QSRhsP1uATads29pJKdLfL7GMy9qtPpyNMoHOa1wPu2BfOSQSx5eWcIUeG0/mNdmHnEPFE97NoVf1p59+wuzZswEA27dvR4cOHbB161Zs3LgRq1atEmSArQFXPCGTShARoJ8KEkvGzt62In4+UrQJ1U8ZOqMyVoytOACuOk2ftfs+NROV9cJkrq9X6Kd+uPV1nA5RAYgM9IFSoxO8Glesv+PECH88NlJ/gX1v81mHm0KX1OjXtDIMEBlo21RY9/hgPGF4vd/6Mx0lNnwgE2NGlDRveKdIpMQHo06txQ8HMgV5Tnt72FkyvFMkIgJ8UFKjwv5LxWaPoQbF3s2hM0phYSFCQvRVgnv27MGtt94KAEhOTkZJifP2sPQ2KqPpzgjDhUUsvew0DhQpOHMHCpWLthSzx83dY9EpOhBV9Rr8dECYCtnGrU44DMPw07FC97MTa8YO0GdOEsL9cL2iHssMO4LYi/vdRgYqILMjiH1idEd0jQ1CaY0Kb2+0fn9sMa5hJM1jGAaPGj5U/JCaiVqV47u+8IGdAxWxxmRSCW7vHQ8AWGdmOpZlWWpQ7OUcelV79OiBxYsX4/jx41i9ejUmTJgAADh79ixSUlIEGWBroDGa7uSmYsXSy86RTcq5PVavOKGAoqGPnfg+cUokDWvtvtmXIcjJv6HViV+T+25M1gd2Qu9AIdbpbkCfaXjzNv055pt9GbjiQFa4sMq2HnaN+cgk+GhKb0glDDaezMNf6dZND1PGzjPd0iMWieH+KKtV49cj1u/PaglXERtiZw87c+7uq5+O3Z6ej6pGswYaHQuuoJzW2Hknh84oH330ET799FP069cPEydOxMCBAwEAixcvxrPPPivE+FoFrt2JXCpBRCA3FSuONXZc1kZmx9oPrpfdVSdk7Owt6nCViT3jkByhP/n/cijL4edryNg1DT56tQ0FAKTnCVclCog/ozS2WzRGd4mCWsvi7T/T7S6k4HvYBVm/vq6xnm1D8J8R7QEAr68/g3IrqtpVIg6ciWUyqQQPG17rr/ddtbiOzVrlAvSwa6xnmxC0jwqAUqPDtjOmHzSM9yimqljv5NCrOmLECBQWFqKiogLff/89f/tbb72Fe+65x9GxtRrcBVQmZRARIK6pWHu2FOPwU7FOqIwVe9Ahk0r4XSq+3JvBT33YK79Rc2Jj3eKCwDBAYZWSzz4JQaxr7Dj6rZRS4COVYN+l4iYXMGvZ08POnKdv6oSO0YEoqlLigy3nWzxeRds6eayp/doiMtAHueV12HTKsf2hKwTqYWeMYRjcbSiiWJ9mOh2rNDoX0YcK7+TwqyqRSBAcHGxyW+fOnWnvQxsY7+4gvqlY+y/uXGXstZIafq2eUFQibHfS2KQb2iA+xBdFVUr8dtSxKZv8ZqZi/X1k6GDIjgqZtVN6wFRhcmQA/jNSnz15d9NZu/Ztbdh1wr6pWI6vXIoP7u4JAPgjLRfVLRR10FSs5/KVS/HA0HYAgBV/ZzjUdkfoNXacO/voA7vUKyV88RUA1Bu97+g67Z0cOqNMmjSp2S9iHS54kpkUT4hjKtbeLcUAID7ED75yCdRaFjllwjX0BFy7V6y9fGQSPGpoj/D5nit2V29qjdpxNC6e4PSI13+4Ss8VrjLWE37HgH7/3iCFDHkV9XattbOnh50l/ZPCkBzhD5VGh38uFDV7rBh39iDWmzkoCYEKGS4UVGHPhUK7n6fCCWvsAP1WiAOSw8GywJ9pDVlFJRVOeD2HXtm2bduafMXHx6OmpgYbNmxAYGCgUGP0aizL8gUKMokI2504MB0nkTBIjuCmY4UtoBD7NCFnWv8EJIT7Ib/S/urNkmolNDoWUgmDKAs7I6TE66vTz+QKl7Gzt9WNq/n5SNE23B9AQ78/WxQ4WDxhjGEYjO+hbxC7rYUiCk/IiBLLQvzkuHegfhvBbx3YaYbfTkzgjB0A3N5HXx275XRDj0UlLQHweg6dUZYtW2by9cUXX2DHjh1YunQpFArHT5KtgcZov0sfqYTP2JXXqQWfvrSHo0UK3BSh0C1P+GySSIsnOI2rN+3p6ccFK1GBCkgtFLGktDFk7K4Ll7HzpE3quaKSggo7AjvDVGy0A8UTxiak6AO73ecKml1byTWJpcDOc90zQB/YHb5aijqVfetoKwzFEyFOCOwmpMRCwgAncyqQXVoLwDiwo/edt3LKK3v//fdj69atznhqr8O1OgH0U7Fh/j5gGG6/WPdvy+bo1l3cOrurAhdQeMo0IaCv3hzFVW9uPGvzehxufV2MmcIJTkqcPmOXXVqHCoHeN560Boxbe3jdxsBOpdHxu7wIMRULAL3bhiI22Bc1Ki1Sr5hvEMt9b8Azfr/EvOQIf8SF+EKtZXHsWpldz1HuhOIJTlSQgu9zufWMPmvHfdjwpYpYr+WUVzYjIwMqlTimEsVOrWvIysmlEkglDJ+SF8N0rCPFE4B+cTsAZJYIG9ipNOIvnuAYV2/uvViE7WcLbHo8l7GLaybwCPGXIyFcH9wIlbVzpIehq3FrDwtsnIpduvsSWBYID/DhC5ccJZEwGJ8SAwDNVuqKvbKbtIxhGAxur9/W70CG5SC+OULvPNHYxJ5xAIDNhu33aCrW+zm04/DLL7/c5LaysjL88ccfmDJliiNP3WqoNcaBnX6aLSJQgbJataGAIshNI9NzdCqWz9gJPBXrKeu/OO0iA/DwiHb4fM8VvLPxLEZ2jrJ6O5/mWp0YS4kLQXZpHdJzKzGkQ6TDY1Y50OrG1eIMvxtb1tgdzCjh1z2+c2eKxWlue4xPicUPB65h57lCaLQ6sztaUMbOOwzqEIF1J3Jx4Irtuy1pdSy/7WCIn7DFE5zxPWLx1p/pOJldjpyy2obiCcrYeS2HXtmjR482+SotLcXrr7+Ozz77TKgxejVujZ1UwvCl52JqecJPxdp58eECu7yKeod7uZmMy4OCDs4TozsiLsQXueV1WP73FasfZ21g14NbZ5fneMaOZVmPqtrkpqnzrZyKLa9V4bk1aWBZYFr/tritV7yg4xnQLhyh/nKU1qhwJNP8FB01KPYOXMbuVE6FzZXvVfVqcCsznLHGDtCvHeWnY0/n0xq7VsChjN3OnTuFGker1VDd2ZAt4DYiF9NUrL0X9zB/OYJ9Zais1yCzpAZdY4NbfpAN41J4QNDB8feR4fWJ3fHEL8ex/J8rmNy3LRIj/Ft8XL6FfWIb4ytjBehlpzZa++kJGSXud2NNxo5lWbz0+ylcr6hH+8gAvHW78NsfyqQSjOsWg9+O5eCv9HwM7hDR5BjK2HmHhHB/tAn1Q255HY5eK8PIzlFWP5abhg3wkTr1fTCxZxwOZpRi0+nrmGmo5LV2xoB4HjqjuBmXsZNLGl4KPmMngl52xj327MEwDNoZKmMzBSyg4KdiZeKuim3s1p6xGNoxAiqNDgu3nbPqMflW9lnjKmOvFFU7vD+tyqgi2xM+2XPZzPJadYuZ4VWHs/FXegHkUgaf3XMDAhQOfb61aALX9uRMPnS6pgUznlR1TJrHBe4HM2ybjm0onHDONCxnfI9YMAxwMruc3wmI3nfey+Yz2owZMwAAq1ev5v9tyerVq+0bVSui0TadUgw39LITw1SsEP3i2kX442R2Oa4W1wo1LI/pY9cYwzB47dbuuPWzfdhxtgCV9WoE+1qegmFZlp9ejGthKjY6yBdRQQoUVSlx7noV+iWF2T1Olca0qEfsgn1l8JNLUafWIr+ini/aaexSQRXe2ZQOAHhxfFf0aBPitDEN7RiJAB8p8ivrcSq3An0SQk3uV3lQZTdp3qD2EVh7LMfmdXZ8c2InTcNyooN8MSA5HIeulvLNiql4wnvZfEYJDAzkmw9z/7b0RVrGndxlkqZTsWLYL1aIysh2kfr3wlUBmxQ7Wq3rTt3jg9EhKgBqLdvi7gRVSg1qDf2xWlpjBxjtQOHgOjsusJNKGEGLCpyFYZgWCyhYlsVzv6ahXq3D8E6ReGhYO6eOyVcuxaiu0QCAv8w0K+YbFEvpAuvpBrXXr2E7nVvR4lZyxsqd2MOusYm99NWxueX6XYAoY+e9bM7YffPNN2b/TeyjMdMnjpuKFcMaO43O8QAqOVK/jixTwIwdH3B66MlpXPdYXPnnCrafLcDtvS0v3OeydaH+cqvWxPRoE4I9F4qQ7uAOFI6urXSHmGBfZBTXWCygKKpS4kxuJSQM8PHU3pC4IGCdkBKLzaeuY9uZfLw4vovJ3py084T3aBvmj4RwP2SX1uFIZilGd4m26nEVTuxh19gEQ3UsV6xBa+y8F51R3MxcO5EIfirWvWvsjLc7s7fdCQC0N2TsMoRcY+fhPcBuNvQ523O+kN+BwBy+ItbK5rkphozdGQczdkozRT1iF9tCxi6zRP/Bok2YH6IFakbcktFdo+EjleBqcQ0uFZpmrKl4wrtw1bEHbZiOdXYPO2PRQb64MTmc/z9l7LyXzRm7SZMmWX3s+vXrbX36VoffJ9YoQOG2FXP3Gju1ya4YjmfsiquVqKpXI6iZNWXWj81zp2IBoE/bUH493MGMUouVdPyuE1YHdvo1YxcLqqDS6OwOGhqCDs/5VB/bQssTrkk2t3+xKwQqZBjeKRK7zhdi25l8dI5p6Eup4rYU89D3MDE1uEMEfj2agwM2FFBwgZ2zetg1dluvOBy+WgqA+th5M5tf2eTkZP4rODgYGzZswJUrVxAWFoawsDBcvnwZGzZsQHCwMG0tvJ25AIWbii2vde9+sWqj7+3IxSfIV86vGxRqOtbTF55LJAzGdddn7bY3s1k8v+uEFevrAKBtmB9C/ORQa1lcLKiye3ye2GONb3liIbC7ZgjskqxoMSOk8UbVscY8/T1MTA0yZOzO5FbwTYdbUl6n//DuiowdoJ+O5VYDUPGE97L5jLJkyRL+S6PR4O2338bp06excuVKrFy5EmfOnMHbb78NrVa4ZrTerGENW8OUF7dfLACU1rova2cc2Dk6JcfvQCHQ1mKO7oghBlxgt+Nsgdl2GID1rU44DMPw07FnHehn50l78XJamoq9ZpiKdWXGDgBu6hoNCQOcvV6JPMPCdYDanXibuBA/JEf4Q8cCRwxZsZZw+zqHuqB4AjCdjnVWmx/ifg6dUXbu3Ilnnnmmye3PPPMMNS+2ktpM8YRUwiDM3/0FFNzYGAYOV0ZyF1Ohthbz9DV2ADCkQwQCfKQorFLiVK75NXHWtjoxJsQ6O3ONs8Wu5YydPrBLcnFgFxGo4FvP7DrXsE8wrbHzPlzWztq2J+UuLJ7gLLgjBVP7tcWkPsLutkLEw6EzikqlwtmzZ5vcnp6eDpXK/RWdnkBtpt0JAERwlbFubHliPE1sXM1nj3ZR+otppmAZu6YBsadRyBraYViajuXX2NkQ2HG92c5YCBat4YlBB5exK6pWQtsoA8qyLP/ec/VULACM7WbIzp4r5G+jjJ334RsVX7UysOPbnbhmjR0AdIsLxkdTeyMiUOGy70lcy6EzyuzZszFlyhR88cUXOHLkCA4fPowvvvgCU6dOxZw5cwQaoncz1+4EaFhnV+zWjJ1wWbF2hiyJUJWx3rI+6WZund3ZArP327rGDmjI2J27XtUkwLGWJ+0Ty4kMVEAqYaDVsShutGtLWa0aVfX6/mKJ4W4I7Ayv84ErxagyrL/ylvcwacBl7NLzKvlp1uZU1Onfk67M2BHv59Ak+8cff4yoqCi88cYbKC3VrymIiIjAc889h5deekmQAXo7lYW1YlxlbKkbtxVzdDsxY1zG7mpRNViWdSgDyLKsx+480dioLtGQSRhcLqzGlaJqdIhqaOyt1Gj5qXhr250A+obQ3C4MV4ur0TE6qOUHNeKJGTuphEF0kALXK+pxvaLeZF0il62LC/F1S/+uDlGBaB8ZgIziGuy9WIxbesQK0vybiEtMsC//Oh+6WoKbU2ItHsuyLCrqXNegmLQeDp1RZDIZXn/9dZSUlCA/Px/5+fkoLi7Ga6+9BpnM9pjx6tWrOH36NKqrrd+hIDs7G0ePHkVlpeMbn7uDxky7E8C4l537MnYqjXDTnUnh+sCusl6DMis+yTZHY5SF8vSLYoifnJ++2dEoa1dYqQ/qFTKJTSd+qYRBd34HCvv+Ljw1cI6xsM7OXRWxxris3c5zBSZ78XpS8ExaNojfN7b5AopalZYP7iljR4Qk2BklJiYGMTExdj32zz//RLdu3TBhwgTMmDEDMTExePPNN5t9TH19PSZPnowuXbpg1qxZiI2NxdKlS+36/u5kabqTm4p1Z2DHVewKETz5+Uj56cSrDk7Hqr3sonizUXWssUuF+nYlcSG+Nmc4uenYE1nldo3JE9udAA1T1vkVdSa3c212XF0Ra4yrgt59vpDfJg7wjvcwaTDEENitT8tFUZXlGReucMJHKoEf7QJBBORwvXNtbS0OHjyIrKwsaDSme+TNnTvXqufIz8/HX3/9hcTERADA7t27cdNNN2HEiBEYO3as2ccsWLAAhw8fxpUrVxAXF4f169fjrrvuwoABAzBw4EDHfigXsjTd2TAV6/41dkJVRraLDMD1inpcLa5xaIN6taYhY+dJVZuWjO0egzc2pON4VhkKq+pRr9Jh6e5LWHciFwCQYMeasCEdIvHjgWtYeywHT9/Uif+gYC1PbHcCGGXsKk0vqFml7qmINdY3MQxh/nKU1aqReqWYv93Ts87E1LjuMegaG4Tz+VV49Y/T+GpWP7MfzPjCCX+5w8VphBhzKLA7efIkbrvtNlRXV6O8vBwxMTEoKNBnHRITE60O7B555BGT/w8fPhwymQy5ubkWH7Ny5Uo89thjiIvTb2w8adIk9OjRAytXrvSowI6bVmw85SWGbcWEnIoFgOTIAKReKUGmgxk7paFHohBtWMQgLsQPvdqG4FROBR7+8RjScyv498WYrtF4fWI3m5/z5u4xSIkPRnpeJT7fcxlv3Nbdpsd7ajsZLmNX0KiXnTsrYjlSCYMxXWPw+/EcbDl9HYD+90sXde+ikEnxybQ+uPPz/dhxtgBrj+Vgav+EJse5uocdaT0cOms///zzmDVrFsrKygDoM2+ZmZkYNmyY1UEdp7y8HPv378eWLVtwzz33oHfv3pg8ebLZY/Py8lBQUIB+/fqZ3D5gwACcOHHC4vdQKpWorKw0+XI3tYV+YbH8tGUtWNa+ykZHNWQThbm4txeoSbFxqxNvuShy07Ens8uh0bEY0TkKfzw+BN/NuRHtjQoqrCWRMHhxQlcAwE8HriGnzLYdP5QeusaO+7u53mgqtqGHnfsCOwAY113f3mb3eX3bE0/LiBLrdI8PxnPjOgMA3tl41uzfX5GhMI7W1xGhOXRWOXbsGF544QUA+o73KpUKSUlJ+Pbbb/H111/b9FwXLlzAyy+/jOeffx67d+/G448/jsBA8xc04wpcYxEREfx95nzwwQcICQnhvxISmn6KcjW1ITMjk5i+FCnxwfCRSlBcreSnkVytYf2fMMGTUE2KuWBY4WFBR3Om9EtAp+hADO8Uid8fG4wfHxyAGxLtn64GgBGdIjGofThUWh2W7Lxk02M9sSoWaJiKLTCaiq2oU/PVxe6cigWA4Z2i4COVoF7tmb9fYr3/jOiAfklhqFJqMP+3U/zuMjodi58OXsMr604DABLD3fueJN7HobNKRUUFwsP125NERUXxU6dxcXEoLCxs7qFNDBw4EPv378f58+exbt06PPbYY1izZo3ZY+Vy/Sec+nrT6Za6ujr4+FheS/TKK6+goqKC/8rOzrZpjM5gaTN7X7kUPdroF8AfzSxz+bgAy2Ozl3GTYkeykHyLGC+6KMaG+GLH8yPx00MD0S8pXJDnZBgGLxmyduuO59i0d6ynrrFrKJ6o599jWYZsXWSgAoFu3kYpQCHDkI4NH0g9rTiFWE8qYfDx1N7wk0txIKME36dmIru0FjO/PYQ31p9BrUqLAcnhmD++i7uHSryMYGeVYcOG4Y033sDBgwfx4osvIiUlxe7nGjVqFAYMGICtW7eavT8hIQESiaTJGrzc3Fy+AMMchUKB4OBgky930zRToMDt6Xf0mnX7DgpN6N0dEsL8IWH0Zf6FzVSLtcQTt7tylxsSwzAhJRY6Fvi/bResfpynrrHjMnZ1ai0qDc1fufV1yW6ehuVwu1AAnhc4E9skRwbgNcMa2YXbzmPCkr1IvVICX7kEb93eHasfGcQvHyBEKA6dVV577TX+3x9++CHS09MxePBgbN26FZ9//rlVz6HT6Zpk3tRqNbKyskymWi9fvsyvn/P398eQIUPw559/8vfX1NRg586dGDdunCM/kss1FzxxlaNuz9gJdPHxkUn4Ck9HWp54ajbJXV4Y3wUSRt8/7WimdR8SPHVXBF+5FGGGNUvcrh1iqIg1ZhLYeVjgTGx338BEjOgcBZVGhxpDlm7bMyPwwNB2kHhB8RcRH4fOKu+99x7/744dO+LEiROoq6tDZmYmBg0aZNVzKJVK9O/fH0uWLMH27dvx66+/4pZbbkFtbS2efPJJ/riFCxdi1qxZJt97/fr1eOWVV/Dnn39i0qRJiI6OblJhK3bN7e7ABXaXCqv50nhXEnqNHWC0zs6hwM7z94l1pY7RgZhmqMr7cNt5q6bBhdxOztW4rB1XQMFVYbu7cIITG+KLXm31+/l6WuBMbMcwDBZN7YW7bmiDd+5MwepHBiE5UhwfMoh3cuis0rNnzya3+frallb28/PD9u3bUVhYiMWLF+PXX3/F6NGjcf78ebRr144/rlOnTujbty///5EjR2LPnj24du0aPv30U6SkpGD//v0WCy7EytJesQAQEahAe8O6tGPXXJ+1U2nNF3Y4op3hhOZIyxNPnSZ0p2fGdoJCJsGRzDLsudDy+lelhxZPAA2VsVzLE7FUxBrjsnae+PsltosO8sXi6X1w/+BkytIRp3NoJXF2djYqKioQEhLi0CDi4+Px/vvvN3uMub1nhw4diqFDhzr0vd2tpSbANyaFI6OoBkcyy3BTN/t29rAX34pFwIsPF9hlCDAVSxk768WF+OH+wUn4et9VrD6cjTFdm38veeqWYoBxAYV+HWfDGjvxZEmm9GuLDWm5mNgzzt1DIYR4GYfO2nfffTe++uorocbSKllqd8Lpn6yfjj3mhgIKbksxIYsUkoXI2Hno+i93u713PAAg9UqJybZs5nhquxPAePeJOtSqNHyhjpgCu/hQP+yaNwpzh7d391AIIV7GoYxddXU1XnzxRaxZswbdu3dv0mrkm2++cWhwrUFLWbH+hsrYkzkVUGq0UMhct6cgt5ZNyClPrknxtZJaaHWsXTtHCL3VWWvRIz4E4QE+KK1R4fi1MgxsH2HxWE8uUDFuecJNw4b6yxFCjWAJIa2AQ2dtiUSC6dOno2PHjlCpVKiurjb5Ii3js2IWApzkCH9EBvpApdHhTG6FK4fmlOm4+FA/+EglUGl1yCuva/kBLhpXayCRMBjRKRIA8M/FomaP5bKinthnraF4oh7X+K3ExJOtI4QQZ3IoY7d69WqhxtFqtVThyTAM+iWF4a/0AhzJLBOsea11Y7NcsWsvqYRBYoQ/LhdW42pxjV0b3Ks9OOhwt5FdorA+LQ//XCzitxwzx5ODZ+PiCb5wwo73GSGEeCLPO2t7GWuCJ75RsYv72Tmr5UWXmCAAsLqnWmMqandit+GdogAA6XmVKKyqt3icJ1cexwX7AQDKatW4YNhtQyzNiQkhxNk876ztZZprd8Lh+tkdu1bK7zfoCs7qF3dTN/1G6NvS8+16vCdnk9wtMlCBnm30Vez7LhZbPI4Lnj1xjV2wnwy+cv24D2XoPzzQVCwhpLXwvLO2l1FZ0bojJT4EvnIJymrVyCh23dpFZ7UVualbDORSBhcLqnGlyPafh9qdOGZkZ33Wrrl1diqNFoBn/o4ZhkFciD5rl2tYx5kcSRk7Qkjr4HlnbS/DFU80NxXrI5Ogd9tQAK6djm3YUkzY6tMQPzmGdNAv4t92xvasndqDW3GIwcgu+sBu36UiaC1kgD29pUxMsMLk/5SxI4S0FjaftWWyhnqLGTNmCDqY1kitsa6lCLfO7ohLAzvh251wbukRCwDYeua6zY91xlZnrckNCaEI8pWhrFaN0xYqrbnpbk8tUIkNbtgBJ1AhQ0SATzNHE0KI97D5rK1QKPhWJmvWrBF8QK2NmsvYtdDPzR2NirmsTUtjs8e47jGQMMCZ3EpkGzZpt5aSpmIdIpNKMKyjoe3JBfPTsWoPXmMHALGGqVgASAz3B8PQhwBCSOtgc7uTwYMHY/z48ejTpw8A4Mknn7R47LJly+weWGvBFU/IWghS+iaFgWGAzJJaFFUpERWkaPZ4IThjSzFORKACA9tF4EBGCbadycfDI6zvwM9lOZ0xrtZiZOcobD2Tj38uFuKZsZ2a3O/pBSqxRlOxtL6OENKa2HzW/vnnnzF8+HAUFBQAAHJycix+kZZZ21Ik2FfOtwlxVdZOo3NuW5Fbeto3HeusNiytyQhDAUVadjnKa1VN7vfkLcWAhl52AK2vI4S0LjZn7NLS0rBw4UIA+uqz9evXCz2mVkXNZ+xanirqnxyG8/lVOJJZhgk9nL95uLMDqPEpsXhzQzqOZ5Ujv6Le5GLcHE8POsQgPtQPnWMCcbGgGvsvF+O2XvH8fSzLNhRPeGjwbDwVSz3sCCGtic1n7VtuucUZ42i1bGnd0d+w60Radrkzh8Rz9nRcTLAv36PvLxt62tFescLg2540WmfHfdgAPDd4Ni6eoIwdIaQ1sfmsHRERgcuXLztjLK2SxoYgpWN0IADw2yQ5mzO2FGvMnupYT88micXIzvpG0f9cLALLNgRz3O8X8NzfcVSQAn5yKRgGaB9FgR0hpPWweSp25syZ6NGjB2JiYgAAycnJFo/NzMy0d1ythtqGdWwJYfoppeJqJepUWvj5SJ07Nie2O+GMT4nFe5vP4fDVUpRUKxER2HJRSEN/Pc8MOsSif3IY/ORSFFYpcT6/Ct3iggE0ZGoBz83YSSUMPr/vBlTVaxAdZN0UPyGEeAObA7slS5ZgypQpuHz5Mh544AG8/vrrzhhXq2FLVizEX44gXxmq6jXIKatFJ0MxhbPH5szKyIRwf/RoE4wzuZXYfrYA9wxIbPExnl6xKRa+cikGd4jA7vOF+PtCER/Yca+7VMJA6oRWN64ypmuMu4dACCEuZ3NgBwDDhg3DsGHDcPDgQcydO1foMbUaWh0LbgZMLrEuSEkI88fZ65XIKnVlYOfci/stPeJwJrcSW8/kWxXYuSKT2FqM7ByF3ecLsfdiER4b1QGAceDsuUEdIYS0Vg5dGVesWCHUOFoltdFaJmunFRPC9dV+tjb1tQcXQDl7ynOCYZ1d6uViVNSqWzze07e7EhOugOLotVLUKDUAAKWG1jASQoincvjMnZaWhpkzZ6Jfv37o27cvZs6cibS0NAGG5v2MAztrd3fg1tlll9U5ZUzGXNUvrkNUIDrHBEKjY7H3kuWN6RuPi6ZiHZccGYDEcH+otSwOXCkBYNxOxrlrOAkhhAjPoSvjhg0b0K9fP+Tn5+OWW27BxIkTkZ+fj379+uHPP/8Uaoxey7ithLVBSkK4IbBzScbO+VWxnCEd9FtcHbvW8l64NFUoLL7tyUV9UM297p66TywhhLRmdq2x47zxxhv45JNP8Mwzz5jc/umnn+L111/HHXfc4dDgvB3X6kTCwOpF6vxUrEsyds7decLYDYmh+D4VOJ7VcmCnpqlYQY3sHIWfDl7jAzsV9QkkhBCP5dCV8fz585gzZ06T2+fMmYMLFy448tStgi2tTjjcVGxOaa1J7zFncOXWXVyj4rN5lahTaVsYFxVPCGlwhwjIpQyySmuRWVxDO3sQQogHc+jMHR0djRMnTjS5/dixY4iKinLkqVsFtR1tO9oaArsqpQYVdS0XGjjClWvZ2oT6ITpIAY2Oxamc8maPpXYnwgpQyPhdTf65WETFKYQQ4sEcOnM//PDDmD59OpYsWYLU1FSkpqZi8eLFmDFjBh5++GGhxui1NDrb17D5+UgRFaRv4ptd6rzpWJZljaZinT8lxzAM+ibqs3bHs8qbPVZFxROCG9mlYZ2diqpiCSHEYzm8xs7Pzw/vvfceSkr0FXURERF48cUX8cILLwgyQG+m0ti3hi0hzA9FVUpkl9WiZ9sQZwzNpLBD5qILfL+kMGxLz29xnR2tsRPeiE5RWLj1PA5cKcHEnnEAKHAmhBBP5NCZOzU1FS+++CKKi4uRn5+PgoICFBcX48UXX0RqaqpQY/RaXMZObmN3f1dUxqrdsF9o36RQAMDxa2XNrh9UU0ZJcN3ighAVpECdWot/rxQDoMCZEEI8kUNn7uHDh/P/jomJQXR0tNn7iHn27nna0MvOeYGdxqQVi2uqI1PiQ+AjlaCkRoWsZoJWfipWRlWbQmEYhm97sud8IQBqd0IIIZ7IKWfu8vJyBAU5d7srb8BNd1rbnJjTsPuE89bYccETY0MrFkf5yqVIaaPfr9TSdKzx2j/K2AlrhCGwKzPs/kFTsYQQ4nnsWmM3c+ZMs/8GAJ1OhzNnzmDQoEGOjawV0NjZJ84VGTvjiliGcV1mrG9iGE5klePYtTLcdUNbM+MyyiRSRklQwztGgmHA719MU7GEEOJ57Dpzy2QyyGQyk39zXwEBAZg+fTp+/vlnQQfqjextJ8Ktscspq4NO55xedq7sYWeM62d3/Fq52ftVblj711qEBfigV9tQ/v/0+yWEEM9jV8bu+++/BwBERkZi0aJFQo6nVbF3y664EF9IJQxUGh2KqpWICfYVzdgcxbU8OZ9fiWqlBoEK07coVzgB0FShM4zsHIWT2eUAKGNHCCGeyKEzNwV1jrF3yy6ZVIK4EH0w56zKWFduJ2YsNsQX8SG+0LHAKUOAYToufWAnlTAuW/vXmnAFFAAFzoQQ4okc6mM3adKkZu9fv369I0/v9fh2J3ZkxRLC/JFTVofsslr0Tw4Xemhum4oFgL5JYcg7dR3Hs8owpGOkyX20j6lz9W4bgmBfGSrrNVQVSwghHsihM3fbtm1NvuLj41FTU4MNGzYgMDBQqDF6LUeyYlxlbFaJcypj1W4MoLjp2GPXmlbG0nZiziWTSjC8kz5r5+cjdfNoCCGE2MqhjN2yZcss3m5uD1liil/HJrEjsHNyZay9u2IIoa+hgOJEdjlYljWpyuWCYcomOc+zYzshUCHD5L5Nq5IJIYSIm1Oujvfffz+2bt3qjKf2KhoHsmLO3n2ioXjC9QFU97hgKGQSlNeqkVFcY3ZclLFznk4xQfhwSi/+PUYIIcRzOOXqmJGRAZVK5Yyn9ioqAaZic8qcOxXr44apWB+ZBL0Me+A2no5VUWBHCCGEWOTQVOzLL7/c5LaysjL88ccfmDJliiNP3SpoHGgpwk3FXq+og1qrEzzQcVdVLKdvUhiOZJbhRFYZpvVP4G9vWGNHxROEEEJIYw4FdkePHm1yW1hYGF5//XU8+uijjjx1q+BI5WlUkAIKmQRKjQ555XVIighwytjcFtglmm9UzP/OZLSwnxBCCGnMocBu586dQo2jVeL3irUj+8QwDNqG+eFKUQ2yS50Y2LmpSIEL7C4WVqGyXo1gX7nJuNwxRUwIIYSInd1X7YKCArvuIw24Pnb2VMUCQGK48ypj3R1ARQUpkBjuD5YFTmSV87dTuxNCCCHEMruujps3b8bDDz9s8f65c+diy5Ytdg+qteAydvZu3eTMyliusMPeoFMI/Q1tT45mlvK3OVJwQgghhHg7u66On376KZ599lmL9z/33HP49NNP7R1Tq9HQx86+rFhDLzvhK2M1bp6KBcDvqHHEKLDj9oqlfUwJIYSQpuy6Op46dQo9e/a0eH+PHj1w6tQpuwfVWjhaoMC1PHFGxs6dO09wBrQzNCrOKuenYN1d1EEIIYSImV1Xx/LycgQFBVm8PygoCGVlTbeDIqY0/LSifcFTW0PGLscpa+wM08RuDKA6RAUizF8OpUaHM3kVABr62PnIqHiCEEIIacyuq3ZSUhIOHz5s8f7Dhw+jXbt2dg+qtXC02S63xq64WoValUawcQHiKFJgGKZhOvZqqcm43BlwEkIIIWJl19Xx7rvvxvz581FeXt7kvvLycrz44ou4++67HR2b19Pw7U7sC1JC/OQI9tV3rBF6Bwq1A82ThXRjsn469kimPgPs7sbJhBBCiJjZ1cfupZdewrp169ChQwc8+OCD6NKlC1iWxcWLF/Hdd98hOjoaL730ktBj9TpcuxNH1rElhPsjPa8S2aW16BxjeXrcVhqd+6diAeBGQ8bu6LVS6HRsQyaRiicIIYSQJuwK7EJDQ5GamoqXX34ZX331FSorKwEAwcHBmD59OhYuXIjg4GBBB+qNVBrHs08JYQ2BnZDEMBULACnxIfCVS1Beq8aVomqHdusghBBCvJ3dO09ERETg66+/xooVK5Cfnw+GYRATEwOplLZ6slZDg2JHMnb6ytisUudMxbo7sPORSXBDQhgOZJTgcGap0ZZiFNgRQgghjTl8dZRKpWjTpg3i4+MpqLOREEFKgpN2n2jYUsz91afcOrujmWVGBSfuHxchhBAiNpT2cCO1ALs7cNuKZZUIHdgZponduPME58Z2+nV2h6+WimaKmBBCCBEjujq6kUaAytP2kYEAgKslNdAZCh6EIKbM2A2JYZAwQG55HbIMawlpKpYQQghpiq6ObiREE+A2YX6QSxmoNDrkVQi3zk4MW4pxAhUypMSHAGjYXoyKJwghhJCm6OroRkL0ipNKGCRFBAAArhbXCDIuQHz94vob1tnVq2kqlhBCCLGEro5uJFTlabtIZwR24morwvWz41BgRwghhDRFV0c34poAO7qOrb0hsMsoEi6wE1uRApex49AaO0IIIaQpujq6kVqg4MmZGTt3bynGiQ7yRXKEP/9/MRR1EEIIIWJDgZ0bqXWOtzsBgGRDYJdZIlxgJ5YtxYwZT8eKaVyEEEKIWNi984SQKioq8M033yA1NRUymQzDhg3DI488AoVCYfExn3zyCf7880+T29q1a4eVK1c6e7iC0QjUUoSbis0urYVKoxNkmlJsU7GAPrD77VgOAJqKJYQQQsxxe2Cn1WrRu3dvTJ06Fffddx9qa2vx7rvv4o8//sCOHTss7mZx8eJFsCyLBQsW8LcFBAS4atiCEKryNCpIgQAfKWpUWmSV1qJjdKAAYxNPHzsO16gYEFfASQghhIiF2wM7qVSKkydPIiQkhL8tJSUFffv2xZEjRzBo0CCLj42KisKoUaNcMErnEGodG8MwaBcVgDO5lbhaXCNQYGcIOkWUGUuO8EdkoALF1Ur4+dD2dYQQQkhjbg/sAJgEdQAQGhoKAKitbX6brGPHjuHWW29FSEgIhg8fjkceeQQymSh+JKsI2VKkXWSgIbCrBhDj8PPxGTsRbCnGYRgG796ZgtQrJeiTEOru4RBCCCGiI8oo6P/+7/8QHR2NgQMHWjzG19cXd999N0aOHInc3FwsXLgQv/32G3bt2gWJhWBEqVRCqVTy/6+srBR87NbS6lhwO4DJBAnshK2MbcjYiWcqFgBu6RmHW3rGuXsYhBBCiCiJLrD78ssv8c0332Djxo3Nrpl7//334e/f0P5izJgx6N69O9auXYtp06aZfcwHH3xgsibPnbiMGCBMSxGhe9kJ1TyZEEIIIa4jqqv2Dz/8gKeeego///wzJkyY0OyxxkEdAHTp0gXJyclIS0uz+JhXXnkFFRUV/Fd2drYQw7YL104EEGoqVtiWJ2LbeYIQQgghLRNNxu6nn37CI488gh9//BHTp0+3+fFarRalpaXw9fW1eIxCoWi2hYoraYwzdhLHM3ZcL7uCSiVqlBoEKBx7aSljRwghhHgeUVy1//e//2Hu3Ln44YcfMGPGDLPHfPTRR3jggQcAAGq1Gp9++ik0Gg0AQKfT4Y033kBVVRXuvvtul43bESpD4MQwgFSAwC7ET46IAB8Ajq+zY1nWqBWLuNbYEUIIIcQyt2fsKioqMHv2bISEhGDFihVYsWIFf9/8+fMxceJEAMCFCxdw5MgRAPoWKTk5OYiPj0dSUhJyc3Mhl8vx+++/o0ePHm75OWyl4QIniQQMI0zw1C4yACU1KlwtrkGPNiEtP8ACLqgDhCnsIIQQQohruD2w8/f3x86dO83e16VLF/7f8+fP56tYJRIJPvroIyxYsADnzp1DWFgYkpKSLDYzFiNnNABuFxmAo9fKHM7YaXQN08S0xo4QQgjxHG4P7ORyuVVNho2DPI6/vz/69evnhFE5H5cVEzIj1i5KmJYnak1Dxo6mYgkhhBDPQekYN+GyYkIGTnzLEwcDO6HX/xFCCCHENSiwcxMuKyZk1Wm7SP1WYleLqsGybAtHW2ZcESvU+j9CCCGEOB8Fdm6i1gmzT6yxpAh/MAxQWa9BWa3a7udp2E6MgjpCCCHEk1Bg5yZqjfB94nzlUsSH+AGAYc9Y+zRsJ0ZvD0IIIcST0JXbTbidJ+QW9rW1VzsBthaj5sSEEEKIZ6Irt5twBQpCTsUCDYGdI5WxtJ0YIYQQ4pnoyu0mfINigYMnIQM7anVCCCGEeBYK7NxE46TgSYhedionVOwSQgghxPnoyu0mKietY2tvlLHT6exreaLhK3bp7UEIIYR4Erpyu4nGCTtPAECbUD/IpQyUGh3yK+vteo6GNXY0FUsIIYR4Egrs3MRZwZNMKkFiuD8A+6djaSqWEEII8Ux05XYTtWGaVCZwuxPAqOWJnYEdtTshhBBCPBNdud2Ea1AsdLsTwKgy1s5ednxgRw2KCSGEEI9CV2434QoUnNErrmO0fs/YvZeKoLWjgIK2FCOEEEI8EwV2bqLmiyeED54mpMQhxE+Oy4XV+P1Yjs2PVzupxx4hhBBCnIuu3G7izHVsIf5yPDWmIwDg4x0XUKfS2jc2moolhBBCPApdud3EWTtPcGYNTkLbMD8UVCrx7f4Mmx5LO08QQgghnokCOzdxdvCkkEkxf3wXAMCKfzJQXK20YWz6oJP2iiWEEEI8C1253UTtpAbFxm7vFY8ebYJRrdRg6a5LVj9OpaF2J4QQQognoiu3m3BVsc6sPJVIGLx6azcAwP8OZVndsLhhSzGaiiWEEEI8CQV2buKqJsBDOkRidJcoaHQsPvrrvFWPoalYQgghxDPRldtNXDEVy3n5lm6QMMCW0/k4dq2sxeNpKpYQQgjxTHTldhNXVp52iQ3ClH5tAQDf7b/a4vG0pRghhBDimejK7SbObnfS2OS++sDucGYpWLb53Sga+tjRGjtCCCHEk1Bg5yYqrWsLFHonhEIuZVBUpURWaW2zx/JBp4TeHoQQQognoSu3m2hcPN3pK5eiR5sQAMDRzObX2amoQTEhhBDikSiwcxONjpuKdV3wdGNyOADg6LXSZo+jLcUIIYQQz0RXbjdxR+Vpv6QwAC1n7NQuXv9HCCGEEGHQldtNuIydzIXr2PobArtLhdUor1VZPI7L2FEfO0IIIcSz0JXbTfjgyYWVpxGBCrSPDACAZvvZUR87QgghxDPRldtN+AbFLq487Z+sz9odaWY6ls8mUvEEIYQQ4lEosHMTtYvbnXD6GwoojjVTQEFTsYQQQohnoiu3m2jcFDxx6+xO5lRAqdGaPYamYgkhhBDPRFduN3HlXrHG2kUGICLAByqNDmdyK8we48rtzgghhBAiHArs3MRdwRPDMHzbE0vr7Ph2J9THjhBCCPEodOV2k4YGxa5/CfhGxRYCO35XDNpSjBBCCPEodOV2AS5QMqY2rGOTSVw/3dnPUBl77FopWJZtcr+Kz9jRVCwhhBDiSSiwc6JLBVXo9fZfGLJwd5P71Dr3FSj0iA+BQiZBWa0aV4pqmtyvdvE+toQQQggRBl25nSjIV47Keg1Ka1TQ6UwzYxo3btvlI5Ogd0IoAOBoZtO2J9TuhBBCCPFMdOV2ovAAHwD69XSV9Wr+dpZljdbYuWe680bDdOxRMztQUMaOEEII8Ux05XYiH5kEwb4yAEBxtZK/nas6BVzf7oTTP4kroDDN2LEs21AVS+1OCCGEEI9CgZ2TRQYpAADF1Sr+NrVRMYW7pjv7JoaBYYDMkloUVTUEnRqd+4NOQgghhNiHrtxOFhmgD+xKjAI7jUnGzj1ZsRB/OTpHBwEw3V5MDEEnIYQQQuxDV24niwjUr7MrqWnIiqmMgid3tDvh9E9u2qhYrWkIOmkqlhBCCPEsFNg5GRfYFZtMdzbsOsEw7gueBrTTr7M7cKWEv40LOhkGkLox6CSEEEKI7Siwc7LIQMMau5qmU7EyN+/sMLhDBADg7PVKlBnGZ1wR686gkxBCCCG2o8DOySICuTV2Tadi3T3VGR3ki84xgQCAAxn6rB3fX4+ydYQQQojHocDOySINvezMFU+IoU/ckA6RAIDUK8UAjIJOmfvHRgghhBDb0NXbybiMnWkfO8M+sSIoThhimI5NvazP2FFzYkIIIcRz0dXbyfiqWDN97MQQPA1sHwEJA2QU1+B6RR1tJ0YIIYR4MLp6OxlXPFGl1KBerQUAo50d3P/rD/GTo2ebEAD6rJ1aJOv/CCGEEGI790cWXi7YV8YHSaWGylONyIKnIR316+z+vVLMB5206wQhhBDieejq7WQMwyAiwHSdnVonjnYnHG6d3YErJVBpxDNNTAghhBDb0NXbBRqvs1NrxFV52j8pHD5SCa5X1ONiQRUAwEck2URCCCGEWE8ckYWXi2xUGcvvPCGSXnF+PlLckBgKAPjnYhEAytgRQgghnoiu3i7QsF+sPmOn4texiSOwA4ChhnV2h66WAqDAjhBCCPFEdPV2AT5jZ9gvViOidiecoR316+xUIpsmJoQQQoj16OrtAhEBphk7Me08wenVNhQBPlL+/2KZJiaEEEKI9cQTWXixxmvsxLJXrDG5VIIB7cJN/k8IIYQQzyKaq7dSqcSJEydw+vRpKJXKlh9gcOnSJezfvx/FxcVOHJ1jGlfFavgtxUTz6wfQsG8sQFOxhBBCiCdy+9WbZVm88cYbSExMxEMPPYSpU6ciMTER69ata/ZxtbW1uPXWW9G/f3889dRTSEhIwKJFi1w0ats0ztjxO0+IbLpziGGdHSCubCIhhBBCrCNz9wBYloVCocClS5cQHBwMAPjggw9w33334cqVK4iPjzf7uLfeegvp6em4dOkSoqOjsWXLFkycOBFDhgzBkCFDXPkjtIjL2JXWqKDTsVDrxFc8AQDdYoMR5i9HWa2a9oolhBBCPJDbr94SiQSvv/46H9QBwIMPPoj6+nqkpaVZfNwPP/yAuXPnIjo6GgBw6623ok+fPvj++++dPGLbcTtPaHQsKuvVUGvEuW2XRMJgsGEXCjG1YiGEEEKIdcQVWRgcPHgQANCpUyez9+fm5qKoqAh9+/Y1ub1v377NBoNKpRKVlZUmX67gI5Mg2FefHC2uVvENisW4u8O0/gkI8ZNjqNF6O0IIIYR4BtEFdvn5+XjyyScxc+ZMi4FdWVkZACA8PNzk9oiICP4+cz744AOEhITwXwkJCcINvAXG6+zUWnFm7ABgVJdopL05Drf0jHP3UAghhBBiI1FFFqWlpZgwYQLatWuHL7/80uJxPj76NWt1dXUmt9fW1vL3mfPKK6+goqKC/8rOzhZm4FYwroxVi7BBsTGGEV8mkRBCCCEtc3vxBKesrAxjx45FYGAgtmzZAn9/f4vHJiQkQCKRICcnx+T2nJwcJCcnW3ycQqGAQqEQasg24TJ2JTVKo50nKIAihBBCiHBEkTLigjp/f39s3boVgYGBTY45d+4cv/bOz88Pw4cPx/r16/n7q6qqsGvXLtx8882uGrZNuIxdcZWyYa9YiSh+/YQQQgjxEm7P2KlUKowfPx45OTn48ssvcejQIf6+7t278+1OPv74Yxw8eBBnzpwBALz//vsYPXo0nnvuOQwePBiff/452rRpg7lz57rl52gJVxlbXKNqyNjJKGNHCCGEEOG4PbCrr69HcHAwevbsiWXLlpncN2/ePD6w6969u8naryFDhuDff//FF198gR9++AGDBw/GCy+8gICAAJeO31qR/Bo7Jfzk+j1Z5ZSxI4QQQoiA3B7YBQcHY+fOnS0e9/zzzze5rX///vjuu++cMSzB8WvsqlWICfEFQGvsCCGEECIsShm5SIRRuxOx7hVLCCGEEM9GkYWLmLY7MewVSxk7QgghhAiIAjsXiTQUT1QpNahWagCIt48dIYQQQjwTRRYuEuwn4zN0BZX1AGgqlhBCCCHCosjCRRiG4Vue5FfoAzsx7hVLCCGEEM9FgZ0LcevslBpD8QS1OyGEEEKIgCiycCGuMpYjl9GvnxBCCCHCocjChbgmxRy5hKZiCSGEECIcCuxcKLJRxo6KJwghhBAiJIosXCgioFHGjoonCCGEECIgCuxcqMkaO8rYEUIIIURAFFm4UJM1dhTYEUIIIURAFFm4UNM1djQVSwghhBDhUGDnQhGNMnY+lLEjhBBCiIAosnCh8EbFE5SxI4QQQoiQKLBzIYVMimBfGf9/2nmCEEIIIUKiyMLFjNfZ0VQsIYQQQoREkYWLGa+zo6lYQgghhAiJAjsXiwhoyNhRuxNCCCGECIkiCxczztjRzhOEEEIIERIFdi7GrbGTShgwDAV2hBBCCBEOBXYuxu0+Qdk6QgghhAiNAjsX4/aLlVOrE0IIIYQIjKILF4swNCmWy+hXTwghhBBhUXThYp1jghCokKFbXJC7h0IIIYQQLyNr+RAipLAAHxx4ZQx85VJ3D4UQQgghXoYCOzcI8pW7ewiEEEII8UI0FUsIIYQQ4iUosCOEEEII8RIU2BFCCCGEeAkK7AghhBBCvAQFdoQQQgghXoICO0IIIYQQL0GBHSGEEEKIl6DAjhBCCCHES1BgRwghhBDiJSiwI4QQQgjxEhTYEUIIIYR4CQrsCCGEEEK8BAV2hBBCCCFeQubuAbgTy7IAgMrKSjePhBBCCCHEPC5O4eKW5rTqwK6qqgoAkJCQ4OaREEIIIYQ0r6qqCiEhIc0ew7DWhH9eSqfTIS8vD0FBQWAYxuHnq6ysREJCArKzsxEcHCzACIm96LUQD3otxINeC3Gh10M8xP5asCyLqqoqxMfHQyJpfhVdq87YSSQStG3bVvDnDQ4OFuUbozWi10I86LUQD3otxIVeD/EQ82vRUqaOQ8UThBBCCCFeggI7QgghhBAvQYGdgBQKBd566y0oFAp3D6XVo9dCPOi1EA96LcSFXg/x8KbXolUXTxBCCCGEeBPK2BFCCCGEeAkK7AghhBBCvAQFdoQQQgghXoICO4Go1WqkpaXh3LlzVm35QYSTlZWF06dPo7a21uIxpaWlOHLkCK5fv+7CkbVex44dQ2pqqtn7ampqcOzYMWRkZLh4VK1PaWkpjh07xu+y05hGo8HJkyeRnp5O5y0nUiqVOHfuHE6ePGnxtQCAc+fO4cSJE1Cr1S4cnXfTarU4dOgQzp07Z/GYuro6HDt2DJcvX3boGNFgicP++ecfNjY2lk1KSmIjIyPZHj16sBkZGe4eltf77bff2K5du7KJiYlsjx492MDAQHbhwoVNjluwYAGrUCjY7t27swqFgp0zZw6r0WjcMOLWYcOGDaxEImEVCkWT+/73v/+xQUFBbOfOndmgoCB29OjRbHl5uRtG6d3q6urYBx98kPXz82P79evHJiQksIsXLzY5JjU1lW3Tpg2bkJDARkdHs127dmUvXLjgngF7sTVr1rBRUVFshw4d2F69erH+/v7sm2++aXJMZmYm26tXLzYyMpJNTk5mY2Ji2D179rhnwF6itraWXbBgAZuYmMgGBQWxd955p9njfvvtNzYkJITt1KkTGxwczA4bNowtKSmx+RgxocDOQVVVVWxUVBT7/PPPsyzLsmq1mh079v/bu/egqMo3DuBf2F1WgVUQHEENUPBGOgkUg2mUIiPueAvGkNTCRpQkFc2KnKym0onUaXSYdBRjytA1dHG5SgKibGGRYWEqYOKiECABrhcuu/D8/nA4P0+Ao4Isrc9nhhnP877veZ/dd9jzeC7LTJoyZYqJMzN/27ZtEx2Ijh07RpaWlpSeni7E0tLSSCqV0qlTp4iIqLS0lOzs7Gj79u19nu+T4OrVqzRy5EhavXp1p8KurKyMZDIZ7dmzh4iIGhoaaNy4cbRs2TJTpGrWlixZQu7u7qTT6YiIqLW1VXjfie4e9IYPH06rVq0iIiKj0UhKpZK8vb1Nkq+5ampqIrlcTh999JEQS0tLIwBUUFAgxKZNm0YBAQHU2tpKRERvv/02OTg40I0bN/o6ZbNRWVlJmzZtooqKCgoJCemysNPpdCSXy2nnzp1ERKTX62nixIkUFhb2UH36Gy7seujAgQMkkUiorq5OiGVnZxMAunDhggkzezJ5eHjQ+++/L2wHBwfTzJkzRX0iIyPp6aef7uvUzJ7RaCR/f3+Ki4ujXbt2dSrsPvzwQ3J2dqb29nYhFhcXRwMGDKA7d+70dbpmq6SkhACQWq3uto9arSYLCwuqqqoSYlqtlgBQUVFRH2T5ZKiuriYAdOzYMSHW0NBAACglJYWI7v5nEwBlZ2cLferq6kgqldL+/fv7PGdz1F1ht2XLFnJwcBBdwYmPjyeZTCYU1Q/Sp7/he+x6qKioCG5ubnBwcBBivr6+QhvrOzU1Nbh69So8PDyEWFFREXx8fET9fH19ceHCBbS0tPR1imbtk08+ga2tLaKiorpsLyoqgre3NywsLISYr68vmpubcfHixb5K0+zl5ORAKpUiKCgI5eXl+OOPPzrdf1pUVIThw4fD2dlZiPHnVu8bNmwY1q1bh5iYGKjVamRmZuK1115DQEAAgoKCAPz//b73c8rBwQGjR4/mtXjMioqKMHnyZEgkEiHm6+sLg8GAP//884H79DdSUyfwX1dfXy8q6gBAoVBAJpOhvr7eRFk9edrb2/HGG2/AxcUFixYtEuJdrY+DgwPa29vR2NiIYcOG9XWqZunkyZPYu3fvfQ9E9fX1cHd3F8U61oZ/V3pPVVUVHBwcEBERAa1WC1tbW1y5cgWfffYZoqOjAXT9eyGTyaBQKHgtetnSpUuRl5eHDRs2wMbGBvX19fjqq68gk8kA3F0LiUTS6Q+8Ozg48Fo8Zt0dHzraHrRPf8Nn7HpIJpOhublZFDMajTAajbCysjJRVk8WIkJERATOnDmDlJQUWFtbC21drU9TUxMA8Pr0EiLC4sWLER4ejrKyMmi1Wvz1118gImi1WuFJZF6LviGTyVBTUwMnJyeUl5fj3LlzSEhIwPr163H69Gmhz7/XAgCam5t5LXpRdXU1/P398corr+Dy5csoLi7Gt99+i5CQEJw4cQLA3bVoa2vr9CRsU1MTr8Vj9iCfSf/Fzy0u7HrI1dUVVVVVoq8K6Nh2cXExYWZPBiLCypUrkZaWhtzcXIwfP17U7urqisrKSlGssrISCoUC9vb2fZmq2Wpvb4ebmxtOnTqFmJgYxMTEIDk5GQaDATExMSgoKADQ/VoA4N+VXuTm5gYAWLlypXDZe+HChbC3t4dWqwVwdy2qq6vR3t4ujKutrYXBYOC16EUnT57ErVu3RLcnBAQEYNy4cUhNTQVwdy2Au8eNe1VVVfFaPGYP8pn0X/zc4sKuhwIDA1FXVyccvABAo9HA2toaU6dONWFm5o+IEBkZCY1Gg9zcXHh6enbqExgYiMzMTBiNRiGm0Wgwc+bMvkzVrEkkEmi1WtHPhg0bYGVlBa1Wi+DgYAB31+Lnn39GbW2tMFaj0WDMmDHCwY31XEBAACQSiehgpNfrcfPmTQwdOhTA3bXQ6/XIy8sT+mg0GlhZWcHf37+vUzZbHe/3tWvXhFhraytqa2uFtilTpsDGxgYpKSlCn4KCAtTW1iIwMLBvE37CBAYGoqioSLQ+Go0GLi4uGDt27AP36XdM+OCG2Vi4cCGNHj2aDh48SLt37yZbW1vasmWLqdMye+vWrSOpVEq7d++m/Px84ae0tFToU11dTU5OThQcHEwpKSn05ptv0sCBA+ns2bMmzNz8dfVUrMFgIG9vb/Lz8yO1Wk2bN28miURChw8fNlGW5uudd94hDw8POnDgAKWmptKMGTPIw8OD9Hq90Gfp0qXk4uJCiYmJtHfvXho8eHCn71djPdPS0kJeXl40adIkOnToEKWlpdHcuXPJzs6OKioqhH6xsbFkY2NDu3btIpVKRe7u7vTyyy+bMHPzUFBQQPn5+fTSSy/RCy+8QPn5+aKvmTEajeTn50c+Pj505MgRio2NJalUSomJiQ/Vp7+xIOKvG++p1tZW7NixA9nZ2ZDL5Vi4cCGWLl1q6rTM3uLFi6HT6TrFlUolNm7cKGzrdDrExsaipKQEI0eORHR0NLy8vPoy1SeORqPBzp07kZOTI4o3NjYiNjYWhYWFsLe3x/LlyzFr1iwTZWm+iAgJCQlITk5Ge3s7fHx8sG7dOtHtBwaDAXFxccjKyoJUKkVISAjCw8NFTy2zntPr9YiLi8Mvv/yC1tZWTJgwAWvXru10GS8xMRGHDh1CS0sLZsyYgejoaMjlchNlbR5mz57d6S99KBQKZGZmCtt6vR5bt25FQUEBBg8ejGXLlmHOnDmiMQ/Spz/hwo4xxhhjzEzwPXaMMcYYY2aCCzvGGGOMMTPBhR1jjDHGmJngwo4xxhhjzExwYccYY4wxZia4sGOMMcYYMxNc2DHGGGOMmQku7Bhj7F9UKhVqampMncYjMRqNUKlU+Oeff0ydCmPMBLiwY4yZ3O3bt6FSqXDmzJlObUQElUol+huzj1tYWBiKi4v7bL7e1NzcjLCwMJSVlZk6FcaYCXBhxxgzuYMHDyIsLAxLlizp1NbW1oawsDCcP3++z/IJDQ2Fk5NTn83HGGO9hQs7xpjJxcfHY82aNSgvL8ePP/4oaktOTgYA5ObmQqVSISMjQ2irq6tDWloaMjIy0NjYKBp37yXJK1euID09HYWFhUL7xYsXodFouiwYFyxYgKFDh4piTU1NOHHiBDIzM9HQ0NDta7l33oqKCmRkZODXX38V9ek4Q3nr1i1RPCkpCVVVVT3Ov0N5eTnS0tK6PBMKAHfu3EF2djYyMjJw7dq1bl9HaWkpkpOTUVpa2u1cjLF+ghhjzITOnTtHEomErl27RosWLaLw8HBR+7JlywgATZ8+nUJDQ2n9+vVERLR//36ytramadOmkZ+fHykUCjp69Kgw7ubNmwSA/P39ady4caRUKsna2prCw8Np1apVNGHCBFIqlTRgwAD68ssvRXMCoOPHjwvbWVlZ5OjoSJ6enhQUFESjR48Wtd+rY945c+bQ2LFjac6cOWRvb09hYWFCn/LycgJAZWVlorFyuZxSU1N7lH/HuNmzZ5OrqysFBQWRQqGgJUuWiOY6fvw4DR06lJ5//nlSKpVkZ2dHn376aaf9KJVKcnd3p5CQEMrIyOhuGRlj/QQXdowxk4qOjqa5c+cSEVFubi7Z2NiQXq8X2g0GAwGgEydOCLHq6mqytbWluLg4Ifbxxx+To6MjNTY2EtH/C5Pg4GAyGo1ERJSUlEQAKCwsjNra2oiIKCEhgWxtbYU+ROLCrqamhmxtbemDDz4Q2hsaGigvL6/L19Mx74IFC8hgMBARUXFxMQGg3377jYgerrB72Pw7xj333HN0+/ZtIiK6cOECDRgwgNRqNRERXb9+nQYNGkTJycnC3CUlJWRjY0M//fSTaD+BgYHU2tra5WtljPU/fCmWMWYyra2t+O6777BixQoAwPTp0zFixAgcPHjwvuPS0tIgk8kQGRkpxN59913o9Xrk5OSI+i5fvhwSiQQAMGXKFABAREQELC0thditW7fw999/dznXkSNHYGlpiU2bNgkxOzs7vPjii/fNMSIiAlKpFAAwceJEODo6oqSk5L5juvKo+UdFRcHa2hoAMH78eMybNw/ff/89AECtVkMikcBoNCIpKQlJSUk4e/YsRo4ciby8PNF+IiMjIZPJHjpvxphpSE2dAGPsyXX06FHcvHkTN27cgEqlAgB4enpi3759QrHXFZ1OB1dXV6HgAYCBAwfC2dkZOp1O1Nfe3l74t1wu7zbW3Nzc5VwVFRVwc3ODlZXVQ722IUOGiLblcnm3c9zPo+bv5uYm2h41ahROnToFALhy5QosLCxw+PBhUZ/JkydjxIgRopizs/ND58wYMx0u7BhjJhMfH49nnnkGqampQkwul+P3339HcXExJk2a1OU4R0dH1NfXd4o3NDTA0dGxV3O0s7Pr9e+E6zjb1t7eLsSMRiOMRmOvzfHvBzzufW8GDRoEiUQiFNP3Y2Fh0Ws5McYeP74UyxgzCZ1Oh5ycHOzbtw8qlUr0ExAQgPj4eACAVCrtdLZr2rRpuHr1quhp06ysLNy+fRt+fn69mmdgYCAqKyuRm5sril+/fv2R9+nk5ARLS0tcunRJiOXn56Otre2R9/lvR48eFf7d0tKC9PR0TJ06FQAwa9YsXL9+HWq1WjSmubm5y4KZMfbfwWfsGGMm8fXXX2PUqFGYOHFip7YFCxYgJiYGX3zxBeRyOZ599lns2LEDdXV1GDJkCJRKJcLDwzFv3jxs2LABRqMRn3/+OdauXYsxY8b0ap7e3t5Yv3495s+fj7Vr1+Kpp55CVlYWpk+fjtWrVz/SPq2srLBo0SKsWbMGFRUVaGxsxDfffCOcyesN6enpWLFiBXx8fJCYmAiZTIaoqCgAgJeXFzZu3IhXX30VUVFR8PT0xOXLl3H48GGoVKpOl5EZY/8dfMaOMWYSTU1NeO+997psmz9/PgIDA4WHDVQqFby9vZGVlSU8HBEfH4+tW7fi/PnzuHTpEvbs2YNt27YJ+5DJZAgNDRVdmpXL5QgNDRXdo2ZjY4PQ0FAoFAoh9u8vKN6+fTuSkpLQ0NCAs2fP4vXXX++2qOtq3o7XNGrUKGE7ISEBb731FgoLC2EwGJCTk4PFixcL97g9av4d43JycjBhwgQUFhbC398fp0+fhq2trTBu8+bN+OGHH0BE0Gq1UCgUyM3NhZeX131fB2Osf7MgIjJ1EowxxhhjrOf4jB1jjDHGmJngwo4xxhhjzExwYccYY4wxZia4sGOMMcYYMxNc2DHGGGOMmQku7BhjjDHGzAQXdowxxhhjZoILO8YYY4wxM8GFHWOMMcaYmeDCjjHGGGPMTHBhxxhjjDFmJriwY4wxxhgzE/8DJ3sEYwD+MR0AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "cutoff_rows = []\n", + "for element in ar.iter_elements():\n", + " if element.z <= 103:\n", + " radius_bohr = cutoff_radius(element.symbol)\n", + " cutoff_rows.append(\n", + " (element.z, element.symbol, radius_bohr, radius_bohr * ar.proatoms.BOHR_TO_ANGSTROM)\n", + " )\n", + "\n", + "smallest = min(cutoff_rows, key=lambda row: row[2])\n", + "largest = max(cutoff_rows, key=lambda row: row[2])\n", + "print(\n", + " f\"Smallest cutoff radius: {smallest[1]} = {smallest[2]:.6f} bohr \"\n", + " f\"({smallest[3]:.6f} Å)\"\n", + ")\n", + "print(\n", + " f\"Largest cutoff radius: {largest[1]} = {largest[2]:.6f} bohr \"\n", + " f\"({largest[3]:.6f} Å)\"\n", + ")\n", + "print(\"Representative radii:\")\n", + "for symbol in (\"H\", \"He\", \"Li\", \"C\", \"O\", \"Fe\", \"U\", \"Lr\"):\n", + " row = next(item for item in cutoff_rows if item[1] == symbol)\n", + " print(f\" {symbol:>2}: {row[2]:.6f} bohr = {row[3]:.6f} Å\")\n", + "\n", + "figure = None\n", + "if plt is not None:\n", + " figure, axis = plt.subplots()\n", + " axis.plot([row[0] for row in cutoff_rows], [row[3] for row in cutoff_rows])\n", + " axis.set_xlabel(\"Atomic number\")\n", + " axis.set_ylabel(\"Cutoff radius (Å)\")\n", + " axis.set_title(r\"Neutral-proatom radius at $\\rho=10^{-4}$ electron/bohr$^3$\")\n", + " figure.tight_layout()\n", + " figure.savefig(ASSET_DIR / \"cutoff-radii.png\", dpi=160)\n", + " plt.close(figure)\n", + "else:\n", + " print(\"Matplotlib is unavailable; the cutoff-radius plot was skipped.\")\n", + "\n", + "figure\n" + ] + }, + { + "cell_type": "markdown", + "id": "31b82eb4", + "metadata": {}, + "source": [ + "\n", + "## Representative pairwise results\n", + "\n", + "The same profiles can support two useful but different answers. The boundary\n", + "mode is the recommended geometry-facing default. The minimum mode is retained\n", + "as a Bader-oriented neutral-promolecular proxy and may explicitly decline to\n", + "return a resolved minimum.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b318ce07", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:30.660171Z", + "iopub.status.busy": "2026-07-14T20:22:30.659988Z", + "iopub.status.idle": "2026-07-14T20:22:30.668872Z", + "shell.execute_reply": "2026-07-14T20:22:30.667791Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pair R/bohr boundary method boundary x minimum status minimum x\n", + "Li-Li 5.0000 homonuclear_midpoint 2.500000000 ok 2.500000000\n", + "H-U 3.8000 equal_proatom_density 1.184253035 ok 1.191871522\n", + "C-O 1.5000 equal_proatom_density 0.556215252 ok 0.611760239\n", + "H-O 12.0000 cutoff_gap_midpoint 5.897488625 low_density_gap None\n", + "H-U 1.5800 equal_proatom_density 0.017084468 no_resolved_interior_minimum None\n" + ] + } + ], + "source": [ + "\n", + "examples = [\n", + " (\"Li\", \"Li\", 5.0),\n", + " (\"H\", \"U\", 3.8),\n", + " (\"C\", \"O\", 1.5),\n", + " (\"H\", \"O\", 12.0),\n", + " (\"H\", \"U\", 1.58),\n", + "]\n", + "\n", + "header = (\n", + " \"pair\", \"R/bohr\", \"boundary method\", \"boundary x\", \"minimum status\", \"minimum x\"\n", + ")\n", + "print(f\"{header[0]:<8} {header[1]:>9} {header[2]:<25} {header[3]:>12} {header[4]:<31} {header[5]:>12}\")\n", + "for atom_a, atom_b, distance in examples:\n", + " boundary = boundary_estimate(atom_a, atom_b, distance)\n", + " minimum = practical_minimum_estimate(atom_a, atom_b, distance)\n", + " bx = \"None\" if boundary.position is None else f\"{boundary.position:.9f}\"\n", + " mx = \"None\" if minimum.position is None else f\"{minimum.position:.9f}\"\n", + " print(\n", + " f\"{atom_a+'-'+atom_b:<8} {distance:9.4f} {boundary.method:<25} {bx:>12} \"\n", + " f\"{minimum.status:<31} {mx:>12}\"\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "69d51b79", + "metadata": {}, + "source": [ + "\n", + "## Why homonuclear symmetry overrides raw shell minima\n", + "\n", + "For Li–Li at 5 bohr, a dense local-minimum scan finds symmetric\n", + "off-centre minima slightly below the midpoint. Returning either off-centre\n", + "point would be a poor pairwise separator for identical atoms. Both estimator\n", + "modes therefore return $R/2$ and preserve the raw profile behaviour only as a\n", + "diagnostic.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f8592476", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:30.671433Z", + "iopub.status.busy": "2026-07-14T20:22:30.671216Z", + "iopub.status.idle": "2026-07-14T20:22:31.017143Z", + "shell.execute_reply": "2026-07-14T20:22:31.016087Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Raw fine-grid valley coordinates near the internuclear region:\n", + " x=2.118430478685 bohr, rho_sum=0.00654500903481724\n", + " x=2.472517881659 bohr, rho_sum=0.00658890668816022\n", + " x=2.500000037765 bohr, rho_sum=0.00658920833374835\n", + " x=2.527482056015 bohr, rho_sum=0.00658890668816022\n", + " x=2.881569505107 bohr, rho_sum=0.00654500903481724\n", + "Symmetry-selected coordinate: 2.500000000000 bohr\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "atom_a, atom_b, distance = \"Li\", \"Li\", 5.0\n", + "xs = [1.75 + (3.25 - 1.75) * i / 1600 for i in range(1601)]\n", + "ys = [objective(atom_a, atom_b, x, distance) for x in xs]\n", + "raw_valleys, _ = grid_valleys(atom_a, atom_b, distance, 0.001)\n", + "\n", + "print(\"Raw fine-grid valley coordinates near the internuclear region:\")\n", + "for x, value in raw_valleys:\n", + " if 1.75 <= x <= 3.25:\n", + " print(f\" x={x:.12f} bohr, rho_sum={value:.15g}\")\n", + "print(f\"Symmetry-selected coordinate: {distance/2:.12f} bohr\")\n", + "\n", + "figure = None\n", + "if plt is not None:\n", + " figure, axis = plt.subplots()\n", + " axis.plot(xs, ys, label=r\"$\\rho_{Li}(x)+\\rho_{Li}(R-x)$\")\n", + " shown = [(x, value) for x, value in raw_valleys if 1.75 <= x <= 3.25]\n", + " axis.scatter([item[0] for item in shown], [item[1] for item in shown], label=\"raw local valleys\")\n", + " axis.axvline(distance / 2.0, linestyle=\"--\", label=\"symmetry midpoint\")\n", + " axis.set_xlabel(\"Position from atom A (bohr)\")\n", + " axis.set_ylabel(r\"Promolecular line density (electron/bohr$^3$)\")\n", + " axis.set_title(\"Li–Li at R = 5 bohr\")\n", + " axis.legend()\n", + " figure.tight_layout()\n", + " figure.savefig(ASSET_DIR / \"li-li-symmetry.png\", dpi=160)\n", + " plt.close(figure)\n", + "else:\n", + " print(\"Matplotlib is unavailable; the Li–Li plot was skipped.\")\n", + "\n", + "figure\n" + ] + }, + { + "cell_type": "markdown", + "id": "aa43c79c", + "metadata": {}, + "source": [ + "\n", + "## Boundary and minimum are different scientific definitions\n", + "\n", + "For C–O at 1.5 bohr, the equal-contribution point and the selected promolecular\n", + "minimum differ visibly. This is not a solver failure: the first balances the two\n", + "reference-atom contributions, while the second minimizes their sum.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9f5d836a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:31.019467Z", + "iopub.status.busy": "2026-07-14T20:22:31.019254Z", + "iopub.status.idle": "2026-07-14T20:22:31.327019Z", + "shell.execute_reply": "2026-07-14T20:22:31.326098Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Boundary coordinate: 0.556215252014 bohr\n", + "Minimum coordinate: 0.611760239435 bohr\n", + "Coordinate difference: 0.055544987420 bohr\n", + "Boundary component densities: 0.414069174671, 0.414069174631\n", + "Minimum component densities: 0.325849689041, 0.475793723795\n" + ] + }, + { + "data": { + "image/png": "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nj17GDdunPEX99SpU7GxseHhhx8mODiYuLg4Fi9enGtLTG6mTZuGq6srw4YN48SJEyQkJPDLL7/w7rvvMnToULp06VKs+O/14IMPsmvXLpYvX05ycjLHjx9n+PDh9O7d26Rcw4YN0Wq1/PPPPyWeB2727NkAzJw5M99y5XWPXfY0PtndrABz5sxhzpw5nDlzhpSUFMLCwpg2bRpLlizh6aefpnnz5oWqOzExkfnz53Pnzh1CQkIYOXIkKpWKGTNmGMuU1WcvBEhiJyohBwcHNm3axOeff8769euNN+J369aNLVu28Pnnnxe5Zc3Z2Zlt27bx7rvv8v333+Pv74+rqytPPvkkbdu25dSpU3Tu3LmUzuj/PvnkE1xdXalbt26O+ePmzZvHjz/+yLp162jatCkeHh6MGjWKmJgYpk2bVmDdgYGBrFmzhgsXLuDn50fHjh1xcXHhrbfeKlIcK1as4PXXX2fWrFlUr16d3r17U7duXQ4cOICPj485LkO+5s+fzzfffMPKlStp0KABgYGBLF26lJdeeqlY9bm6urJ//34GDx7MK6+8gpeXF40aNWLGjBl0797d2P3u5+fHzp070Wg0tGrVisDAQMLCwnjjjTeAnK1k9/Lx8eHAgQPUq1eP3r174+HhwaxZs3j99ddZsWJFsWLPzZQpU5gyZQrTpk2jevXqPP/888yZM4fGjRublKtZsyaff/45q1atwtHRsdjz2EHW/62xY8fyxx9/5Jh6pzR16dLFOFcfwM8//2zsyr17XsHcTJgwAbVazdixY6levTrNmjVj586dfPvtt0VaueaNN97gzp07NG7cmBYtWqDT6dixYwf169c3limrz14IAJVS2CGBQgghcli/fj2DBg1i27Zt9OzZs7zDEUJUcdJiJ4QQJbBq1SrjqhpCCFHeJLETQohCev755/n333+Ji4sjMjKS9957j2XLlvH666/j6OhY3uEJIYR0xQohRGEdPHiQ2bNnc+TIEeLj42nQoAHPPvssL7zwQnmHJoQQgCR2QgghhBAWQ7pihRBCCCEshCR2QgghhBAWQlveAZQng8HAtWvXcHJyKvGST0IIIYQQhaUoComJidSqVQu12nztbFU6sbt27VqZTKgqhBBCCJGbiIgIateubbb6qnRi5+TkBGRdVGdn53KORoiKy2BQuBaXCkAtVzvUastp4VYUBZ0h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", + "text/plain": [ + "
" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "atom_a, atom_b, distance = \"C\", \"O\", 1.5\n", + "boundary = boundary_estimate(atom_a, atom_b, distance)\n", + "minimum = practical_minimum_estimate(atom_a, atom_b, distance)\n", + "xs = [distance * i / 1000 for i in range(1001)]\n", + "rho_a_values = [rho(atom_a, x) for x in xs]\n", + "rho_b_values = [rho(atom_b, distance - x) for x in xs]\n", + "total_values = [a + b for a, b in zip(rho_a_values, rho_b_values)]\n", + "\n", + "print(f\"Boundary coordinate: {boundary.position:.12f} bohr\")\n", + "print(f\"Minimum coordinate: {minimum.position:.12f} bohr\")\n", + "print(f\"Coordinate difference: {abs(boundary.position-minimum.position):.12f} bohr\")\n", + "print(f\"Boundary component densities: {boundary.rho_a:.12g}, {boundary.rho_b:.12g}\")\n", + "print(f\"Minimum component densities: {minimum.rho_a:.12g}, {minimum.rho_b:.12g}\")\n", + "\n", + "figure = None\n", + "if plt is not None:\n", + " figure, axis = plt.subplots()\n", + " axis.plot(xs, rho_a_values, label=r\"$\\rho_C(x)$\")\n", + " axis.plot(xs, rho_b_values, label=r\"$\\rho_O(R-x)$\")\n", + " axis.plot(xs, total_values, label=\"sum\")\n", + " axis.axvline(boundary.position, linestyle=\"--\", label=\"proatomic boundary\")\n", + " axis.axvline(minimum.position, linestyle=\":\", label=\"promolecular minimum\")\n", + " axis.set_xlim(0.35, 0.90)\n", + " axis.set_ylim(0.15, 2.8)\n", + " axis.set_xlabel(\"Position from C (bohr)\")\n", + " axis.set_ylabel(r\"Density (electron/bohr$^3$)\")\n", + " axis.set_title(\"C–O interatomic region at R = 1.5 bohr\")\n", + " axis.legend(loc=\"upper right\")\n", + " figure.tight_layout()\n", + " figure.savefig(ASSET_DIR / \"c-o-method-comparison.png\", dpi=160)\n", + " plt.close(figure)\n", + "else:\n", + " print(\"Matplotlib is unavailable; the C–O plot was skipped.\")\n", + "\n", + "figure\n" + ] + }, + { + "cell_type": "markdown", + "id": "442a4cb8", + "metadata": {}, + "source": [ + "\n", + "## Practical minimum search against a slower reference\n", + "\n", + "The public minimum mode is not intended to enumerate every mathematical local\n", + "minimum. It asks whether a valley is resolved at 0.01 bohr and checks it at two\n", + "grid scales, with one finer fallback. The comparison below uses 300 deterministic\n", + "H–Lr cases, plus seven targeted numerical edge cases, against a slower 0.001-bohr\n", + "fine-grid search over the same cutoff-bounded interval.\n", + "\n", + "A dense-reference minimum may be intentionally rejected when it is narrower\n", + "than the documented resolution or lies immediately beside a nucleus at an extreme\n", + "short distance.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "0767eaed", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:31.329125Z", + "iopub.status.busy": "2026-07-14T20:22:31.328954Z", + "iopub.status.idle": "2026-07-14T20:22:33.542661Z", + "shell.execute_reply": "2026-07-14T20:22:33.541467Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Cases: 307\n", + "Both methods returned a minimum: 298\n", + "Both returned no resolved minimum: 7\n", + "Practical-resolution nonmatches: 2\n", + "Joint results within 0.01 bohr: 298/298\n", + "Largest position difference: 0.0012907 bohr for C-O at R=1.5 bohr\n", + "Largest relative density difference: 5.96583e-06\n", + "Practical search time: 0.244 s\n", + "Slower reference time: 1.076 s\n", + "\n", + "Intentionally unresolved dense-reference minima:\n", + " C-Lu R=0.221730136797: status=no_resolved_interior_minimum, reference_x=0.00284120268288 bohr\n", + " He-Ru R=0.655290907435: status=no_resolved_interior_minimum, reference_x=0.0112558333887 bohr\n" + ] + } + ], + "source": [ + "\n", + "adversarial_cases = [\n", + " (\"Ni\", \"Te\", 1.5897045597171517),\n", + " (\"Al\", \"Pb\", 1.3645306063699802),\n", + " (\"Pr\", \"Re\", 0.7592738817632778),\n", + " (\"C\", \"Lu\", 0.2217301367970158),\n", + " (\"He\", \"Ru\", 0.6552909074349161),\n", + " (\"H\", \"U\", 1.58),\n", + " (\"C\", \"O\", 1.5),\n", + "]\n", + "cases = deterministic_cases(300) + adversarial_cases\n", + "\n", + "start = time.perf_counter()\n", + "practical_results = [practical_minimum_estimate(*case) for case in cases]\n", + "practical_seconds = time.perf_counter() - start\n", + "\n", + "start = time.perf_counter()\n", + "reference_results = [dense_reference_minimum(*case) for case in cases]\n", + "reference_seconds = time.perf_counter() - start\n", + "\n", + "joint_errors = []\n", + "intentional_nonmatches = []\n", + "both_absent = 0\n", + "for case, practical, reference in zip(cases, practical_results, reference_results):\n", + " if practical.position is None and reference is None:\n", + " both_absent += 1\n", + " elif (practical.position is None) != (reference is None):\n", + " intentional_nonmatches.append((case, practical, reference))\n", + " else:\n", + " joint_errors.append(\n", + " (\n", + " abs(practical.position - reference[0]),\n", + " abs(practical.rho_sum - reference[1]) / reference[1],\n", + " case,\n", + " )\n", + " )\n", + "\n", + "max_position = max(joint_errors, key=lambda item: item[0])\n", + "max_density = max(joint_errors, key=lambda item: item[1])\n", + "within_resolution = sum(error[0] <= PRACTICAL_RESOLUTION for error in joint_errors)\n", + "\n", + "print(f\"Cases: {len(cases)}\")\n", + "print(f\"Both methods returned a minimum: {len(joint_errors)}\")\n", + "print(f\"Both returned no resolved minimum: {both_absent}\")\n", + "print(f\"Practical-resolution nonmatches: {len(intentional_nonmatches)}\")\n", + "print(f\"Joint results within 0.01 bohr: {within_resolution}/{len(joint_errors)}\")\n", + "print(\n", + " f\"Largest position difference: {max_position[0]:.6g} bohr \"\n", + " f\"for {max_position[2][0]}-{max_position[2][1]} at R={max_position[2][2]:.6g} bohr\"\n", + ")\n", + "print(f\"Largest relative density difference: {max_density[1]:.6g}\")\n", + "print(f\"Practical search time: {practical_seconds:.3f} s\")\n", + "print(f\"Slower reference time: {reference_seconds:.3f} s\")\n", + "\n", + "if intentional_nonmatches:\n", + " print(\"\\nIntentionally unresolved dense-reference minima:\")\n", + " for case, practical, reference in intentional_nonmatches:\n", + " print(\n", + " f\" {case[0]}-{case[1]} R={case[2]:.12g}: \"\n", + " f\"status={practical.status}, reference_x={reference[0]:.12g} bohr\"\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "b9d1be2c", + "metadata": {}, + "source": [ + "\n", + "## Cached timing comparison\n", + "\n", + "Timing is machine-dependent, so these figures are evidence rather than portable\n", + "thresholds. The important point is the scale difference: the boundary mode\n", + "needs one cached contour calculation plus a monotone bisection, while the\n", + "optional minimum mode remains below about a millisecond per ordinary pair in\n", + "this standalone pure-Python study.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6a476f18", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-14T20:22:33.544904Z", + "iopub.status.busy": "2026-07-14T20:22:33.544722Z", + "iopub.status.idle": "2026-07-14T20:22:40.750962Z", + "shell.execute_reply": "2026-07-14T20:22:40.749824Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "boundary: median 0.0541 ms/pair; runs=[0.05746927700010929, 0.055642846000182544, 0.05410611999991488, 0.05177689599986479, 0.050906101000009585]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minimum: median 0.7887 ms/pair; runs=[0.8100914009999087, 0.7820286119999764, 0.7892370699998992, 0.7887017269999888, 0.7813305360000413]\n" + ] + } + ], + "source": [ + "\n", + "import statistics\n", + "\n", + "timing_cases = deterministic_cases(1000)\n", + "for case in timing_cases[:30]:\n", + " boundary_estimate(*case)\n", + " practical_minimum_estimate(*case)\n", + "\n", + "for label, function in (\n", + " (\"boundary\", boundary_estimate),\n", + " (\"minimum\", practical_minimum_estimate),\n", + "):\n", + " samples = []\n", + " for _ in range(5):\n", + " start = time.perf_counter()\n", + " [function(*case) for case in timing_cases]\n", + " samples.append((time.perf_counter() - start) * 1000.0 / len(timing_cases))\n", + " print(f\"{label:>8}: median {statistics.median(samples):.4f} ms/pair; runs={samples}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "48e2df3d", + "metadata": {}, + "source": [ + "\n", + "## Why exhaustive local-minimum enumeration is out of scope\n", + "\n", + "The exhaustive analysis showed that an exact “return every\n", + "mathematical local minimum” contract really does require disproportionate\n", + "numerical machinery:\n", + "\n", + "| Case | Exact-topology observation | Consequence for the numerical policy |\n", + "|---|---|---|\n", + "| H–F near a knot | A minimum occurred 27 binary64 coordinates below a knot and needed high-precision derivative signs. | Important only when every microminimum is binding. |\n", + "| C–O exact knot | The stored knot and its immediate neighbor could differ by one density ULP. | Exact-knot topology is below the declared 0.01-bohr resolution. |\n", + "| Li–Li, 5 bohr | Five local minima were found; the two off-centre minima were slightly deepest. | A raw minimum can violate the desired homonuclear separator symmetry. |\n", + "| H–U, 1.58 bohr | Fourteen isolated minima were found while a boundary value was lower. | Counting minima does not produce a useful IAS estimate. |\n", + "| At–Bk event collision | Two exact-distinct profile events rounded to one float and caused orientation-dependent candidate counts in an event-based enumerator. | Exact event identity is unnecessary for the documented resolution-limited estimator. |\n", + "| Ne–Ne open segment | Ordinary log/exp value error exceeded a stringent global tie tolerance. | Numerical tie lists require a separate high-precision value model. |\n", + "\n", + "These cases illustrate genuine shell-structure and floating-point effects. They\n", + "lie outside the documented resolution-limited semantics: boundary mode is\n", + "unaffected, and\n", + "minimum mode deliberately coalesces or rejects sub-resolution structure.\n" + ] + }, + { + "cell_type": "markdown", + "id": "9abe91dc", + "metadata": {}, + "source": [ + "\n", + "## Public interface and scope\n", + "\n", + "The public IAS API has three entry points:\n", + "\n", + "```python\n", + "estimate_proatomic_boundary(...)\n", + "estimate_promolecular_density_minimum(...)\n", + "estimate_ias_position(..., mode=\"boundary\" | \"minimum\")\n", + "```\n", + "\n", + "`boundary` is the default. It is the best fit for stable geometry and\n", + "Laguerre/Voronoi calibration. `minimum` remains available for Bader-oriented\n", + "research, but it searches only the significant-overlap region, returns one\n", + "resolved candidate rather than every local minimum, and reports ambiguity or a\n", + "non-result explicitly.\n", + "\n", + "For identical atoms, both modes return exactly `R/2`. Extreme short-distance\n", + "cases are handled through explicit statuses such as `one_atom_dominates` or\n", + "`no_resolved_interior_minimum`, rather than a universal distance exclusion.\n", + "\n", + "The remaining scientific limitation is deliberate: neither neutral-proatom\n", + "mode has yet been calibrated against a curated set of molecular-density QTAIM\n", + "interatomic-surface coordinates.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/05-proatomic-density-and-ias.ipynb b/docs/notebooks/05-proatomic-density-and-ias.ipynb new file mode 100644 index 0000000..b3db3bb --- /dev/null +++ b/docs/notebooks/05-proatomic-density-and-ias.ipynb @@ -0,0 +1,414 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Neutral proatomic density and pairwise interatomic-surface (IAS) proxy workflows\n", + "\n", + "This notebook demonstrates the public `atomref` density and pairwise APIs. The profiles are frozen, isolated, neutral, spherical proatoms—not molecular electron densities. The pairwise results are useful reference-atom models, but neither mode is an exact molecular QTAIM zero-flux surface or critical point.\n\n**Prerequisites.** Install `atomref[notebooks]` for Matplotlib support. Familiarity with scalar function calls and ordinary radial-density plots is sufficient; the scientific assumptions and mode distinction are introduced before use." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "The package has no runtime plotting dependency. This notebook uses the optional `notebooks` extra for Matplotlib and selects a non-interactive backend so plots render reproducibly in scripts and documentation." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "atomref 0.2.1; matplotlib 3.11.0\n" + ] + } + ], + "source": [ + "from __future__ import annotations\n", + "\n", + "import math\n", + "from pathlib import Path\n", + "import sys\n", + "\n", + "ROOT = Path.cwd().resolve()\n", + "if not (ROOT / \"src\" / \"atomref\").is_dir():\n", + " for candidate in (ROOT.parent, ROOT.parent.parent):\n", + " if (candidate / \"src\" / \"atomref\").is_dir():\n", + " ROOT = candidate\n", + " break\n", + "if not (ROOT / \"src\" / \"atomref\").is_dir():\n", + " raise RuntimeError(\"Run this notebook from the atomref repository checkout\")\n", + "sys.path.insert(0, str(ROOT / \"src\"))\n", + "\n", + "import atomref as ar\n", + "import matplotlib\n", + "matplotlib.use(\"Agg\")\n", + "import matplotlib.pyplot as plt\n", + "\n", + "print(f\"atomref {ar.__version__}; matplotlib {matplotlib.__version__}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dataset discovery and provenance\n", + "\n", + "The radial dataset uses the same registry and `get_builtin_set()` dispatcher as scalar tables. Notice the typed radial payload, complete H–Lr coverage, exact upstream release and dataset identity, 20-bohr public limit, and separate CC BY 4.0 data license." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Quantity: 'proatomic_density'; domain='element'; units='electron/bohr^3'\n", + "Dataset: pbe0_sfx2c_dyallv4z_h-lr_neutral_v2\n", + "Payload: ElementRadialSet; profiles=103; grid rows=1127\n", + "Source: atomref-proatoms 2.0.0, pbe0_sfx2c_dyallv4z_h-lr_spherical_v2\n", + "Method: PBE0 self-consistent spherical fractional-occupation UKS with spin-free one-electron X2C and the dyall-v4z basis.\n", + "Public radius limit: 20.0 bohr; data license: CC BY 4.0\n", + "DOIs: 10.5281/zenodo.21291021, 10.5281/zenodo.21291022\n" + ] + } + ], + "source": [ + "quantity = ar.get_quantity_info(\"proatomic_density\")\n", + "info = ar.get_proatomic_density_set_info()\n", + "dataset = ar.get_builtin_set(info.ref)\n", + "storage = info.storage\n", + "\n", + "print(f\"Quantity: {quantity.quantity!r}; domain={quantity.domain!r}; units={quantity.units!r}\")\n", + "print(f\"Dataset: {info.ref.set_id}\")\n", + "print(f\"Payload: {type(dataset).__name__}; profiles={info.coverage.n_values}; grid rows={len(dataset.radii)}\")\n", + "print(f\"Source: {storage['source_project']} {storage['source_release']}, {storage['source_dataset_id']}\")\n", + "print(f\"Method: {info.method_summary}\")\n", + "print(f\"Public radius limit: {storage['public_max_radius_bohr']} bohr; data license: {storage['data_license']}\")\n", + "print(\"DOIs:\", \", \".join(reference.doi for reference in info.references if reference.doi))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Profile retrieval and scalar evaluation\n", + "\n", + "Profiles are immutable views on the shared grid. Radius input and density output units are independent: the two evaluations below use different radius units for the same physical point, while the final value requests electron/ų explicitly." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Profile: O; Z=8; contract=loglog_positive_bracketed_v1\n", + "rho_O(0.75 Å) = 0.114179969338 electron/bohr^3\n", + "same point = 0.114179969338 electron/bohr^3\n", + "converted output = 0.770524625676 electron/angstrom^3\n" + ] + } + ], + "source": [ + "oxygen = ar.get_proatomic_density_profile(\"O\")\n", + "assert oxygen is not None\n", + "radius_angstrom = 0.75\n", + "radius_bohr = radius_angstrom / ar.BOHR_TO_ANGSTROM\n", + "rho_native_from_angstrom = oxygen(radius_angstrom)\n", + "rho_native_from_bohr = oxygen(radius_bohr, radius_unit=\"bohr\")\n", + "rho_angstrom = oxygen(\n", + " radius_bohr, radius_unit=\"bohr\", density_unit=\"electron/angstrom^3\"\n", + ")\n", + "assert math.isclose(rho_native_from_angstrom, rho_native_from_bohr, rel_tol=1e-14)\n", + "\n", + "print(f\"Profile: {oxygen.symbol}; Z={oxygen.atomic_number}; contract={oxygen.interpolation_contract}\")\n", + "print(f\"rho_O(0.75 Å) = {rho_native_from_angstrom:.12g} electron/bohr^3\")\n", + "print(f\"same point = {rho_native_from_bohr:.12g} electron/bohr^3\")\n", + "print(f\"converted output = {rho_angstrom:.12g} electron/angstrom^3\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Radial-density profiles\n", + "\n", + "A logarithmic density axis exposes both the compact core and long neutral tail. These curves are isolated-atom references sampled through the public scalar evaluator; they are not densities of atoms embedded in a molecule." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plotted C and O profiles from 0.01 through 10 bohr.\n" + ] + }, + { + "data": { + "image/png": "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U6dOVfWrQmZz5QQymdk1ko4OeclJaEeOHFEnaxlJl4KbCQgIUB9Pnjxp2rZmzZp8+8iJbcbg261bN3z77bcqnBrHdj3ZR05Ku55xIQpjra+UbbRv3940drk/qe01+vvvv1UQb9WqFUqLjEv6ERMRlQRLGoiIStGwYcPUog1Sxyqzu1LvKidxSW2qnKQlnQakvCEvCY9SCtGrVy+MHTsWFy5cUF0QCpvtNJYgSKmE3K98jczizpw5M98+Ulcs3SD69eunSgxkdllmSRs1alTgfcqJctKBQTpGSLlB3vAtM8oymy0zxtI9QepxhXSVkNpjaR0m7dikS4N0gZCT0QoqnSiJffv2qbIP6cNLRFQSnOElIiohCXtdunTJt01C6pdffqlOvJIQKh0IpMxBWpZJjW/Xrl1VP9n7778/39fJyWwvvvii6k8r9yGruEmbLmlPVtjCE7///rsKnsYT4NauXav2MX6NLNgwadIkNQ6ZIZUeu3K/xhPQriczqLJghjE4S+iV+5OZY2kPJo8jdcByHzJrnDdYf/DBB2qWV/rsSiiW60bG+7n+hDgJ7MZQXNg+QjpASLC/vgaYiKiouLQwERGZ7N27F3/88QemTJliEUdFTlaTE+3kjwiZWSYiKgkGXiIiIiLSNZY0EBEREZGuMfASERERka4x8BIRERGRrjHwEhEREZGuMfASERERka4x8BIRERGRrjHwEhEREZGuMfASERERka4x8BIRERGRrjHwEhEREZGuMfASERERka4x8BIRERGRrjHwEhEREZGuMfASERERka4x8BIRERGRrjHwEhERERH07P8B97Mxaw9WedoAAAAASUVORK5CYII=", + "text/plain": "
" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "radii_bohr = [10 ** (-2 + 3 * index / 300) for index in range(301)]\n", + "figure, axis = plt.subplots(figsize=(7.0, 4.2))\n", + "for symbol in (\"C\", \"O\"):\n", + " profile = ar.get_proatomic_density_profile(symbol)\n", + " assert profile is not None\n", + " values = [profile(radius, radius_unit=\"bohr\") for radius in radii_bohr]\n", + " axis.plot(radii_bohr, values, label=symbol)\n", + "axis.axhline(ar.PROATOMIC_TAIL_CUTOFF, color=\"black\", linestyle=\"--\", label=\"pairwise cutoff\")\n", + "axis.set(xlabel=\"Radius (bohr)\", ylabel=r\"Density (electron/bohr$^3$)\", title=\"Neutral spherical proatomic densities\")\n", + "axis.set_yscale(\"log\")\n", + "axis.set_ylim(1e-8, None)\n", + "axis.legend()\n", + "figure.tight_layout()\n", + "print(\"Plotted C and O profiles from 0.01 through 10 bohr.\")\n", + "figure" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Ordinary heteronuclear pair: boundary and minimum\n", + "\n", + "For C–O at 1.5 bohr the fixed cutoff contours overlap. The stable default `boundary` balances the two proatomic contributions; the optional `minimum` instead selects a resolved valley in their sum. The distinct coordinates are expected because the modes answer different questions." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "boundary: method=equal_proatom_density; status=ok; x_A=0.556215252014 bohr; rho_sum=0.828138349302\n", + " minimum: method=promolecular_density_minimum; status=ok; x_A=0.611760239317 bohr; rho_sum=0.801643412838\n", + "coordinate difference: 0.055544987303 bohr\n", + "cutoff=0.0001 electron/bohr^3; regime=overlap\n" + ] + } + ], + "source": [ + "atom_a, atom_b, distance = \"C\", \"O\", 1.5\n", + "boundary = ar.estimate_proatomic_boundary(atom_a, atom_b, distance, distance_unit=\"bohr\")\n", + "minimum = ar.estimate_promolecular_density_minimum(atom_a, atom_b, distance, distance_unit=\"bohr\")\n", + "dispatched = ar.estimate_ias_position(atom_a, atom_b, distance, mode=\"boundary\", distance_unit=\"bohr\")\n", + "assert boundary is not None and minimum is not None and dispatched == boundary\n", + "\n", + "for label, result in ((\"boundary\", boundary), (\"minimum\", minimum)):\n", + " print(f\"{label:>8}: method={result.method}; status={result.status}; x_A={result.position_from_a:.12f} bohr; rho_sum={result.rho_sum:.12g}\")\n", + "print(f\"coordinate difference: {abs(boundary.position_from_a - minimum.position_from_a):.12f} bohr\")\n", + "print(f\"cutoff={boundary.cutoff_density:g} {boundary.density_unit}; regime={boundary.cutoff_regime}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Component and summed line densities\n", + "\n", + "The dashed boundary line crosses where the C and O contributions are equal. The dotted minimum line sits at the valley of their sum, so it need not divide the component densities equally. Coordinates are measured from atom A (C) toward atom B (O). Swapping the pair maps each coordinate `x` to `R - x` and exchanges the C/O component fields." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The component crossing and summed-density minimum are marked separately.\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArwAAAGkCAYAAADT1/YoAAAAK3RFWHRTb2Z0d2FyZQBhdG9tcmVmIFN0YWdlIDUgbm90ZWJvb2sgZXhlY3V0aW9uhaWyQgAAAAlwSFlzAAAPYQAAD2EBqD+naQAApw5JREFUeJztnQd4FFUXhr/0RgiBEFrovffeQbogShUVUaoUQUTFCthQBBURBQtFLFTlpwhI7733XhIghEBI79n/OXezm90USNlkS743z3122s7cvTuZ/ebMKXYajUYDQgghhBBCbBR7c3eAEEIIIYSQ3ISClxBCCCGE2DQUvIQQQgghxKah4CWEEEIIITYNBS8hhBBCCLFpKHgJIYQQQohNQ8FLCCGEEEJsGgpeQgghhBBi01DwEkIIIYQQm4aClxBCCCGE2DQUvIQQQgghxKah4CWEEEIIITYNBS8hhBBCCLFpKHgJIYQQQohNQ8FLCCFWhqurKyZMmGDubtj0+AUHB8POzg7ffvvtY5dZApZyPmR1fCx1PIltQsFLyGOIiorC119/jZYtW8Lb2xtubm6oXLkyevfujf/9739ITEzM8viFh4fjk08+Qb169VCgQAF4eHigbt26+Pjjj9U6Yn4CAgLUD/H3339v7q4QYtXwf4lYCo7m7gAhloq/vz+6dOmiF6jdunVDoUKFcPXqVfz888/o06cPNm7ciKeeeirT+7x58yY6deqEhIQEzJo1Cx07doS9vT22bt2KiRMn4vfff8fmzZtRtmzZXP1shJCs4+PjA41Gw6Ez0fhwPEleQsFLSDrIRfu5555DUFAQTpw4AT8/P/26GjVq4JtvvlECWCy+WdmniOSHDx/i1KlTKFmypH7dM888g8aNG6NOnTro27cvDh06pCyMhBBCCMk5dGkgJB3Wr1+PI0eO4O233zYSu4Z07txZuTpkZZ9Hjx7FpEmTjMSuDln25ptvquPKto8jPj4eX375JWrXrq1Et/Rx6NChuH37tn6bR48eYfz48ShTpgycnZ1RunRpjBs3Ti3XceXKFSWsf/nlF2VdrlKlinKxEMu2PIoUlixZgqpVqyo/webNm+Ps2bNGfTHch2wrLh+ybf369dP9HFnt16pVq1C9enW4uLioG4JNmzalOx7Tp09XNyNy7MKFC2PAgAG4ceNGlve5Z88e1SdB+iXvkTZ27NjHfieOjo76beVzVahQAW+99RYiIyOz/bnu3buHF198UT1ZkDZ48GCEhYUhs8yePVudI+I6I+eXuOIcP35cv37Hjh2qP/KkIr3PI+dqbp0r2d1HZr9vU4xfZnxOc+NczYisfB5T/k9k9nxKPT5P+l/KyIc3s2P0pP4QYoSGEJKGMWPGyHM5zalTp0y+z5MnT2a4zYkTJ9Q2Y8eOzXCbhIQETZcuXTSFChXSLFq0SBMUFKS5c+eOZuHChZopU6aobWJiYjT169fXlChRQrNp0yZNaGioZsuWLZpSpUpp6tSpo4mKilLbXb58WR3v2Wef1UyaNEkTGBiouXLlitqmadOmmqVLl2omTJig9n/t2jVNvXr1NDVq1NAkJSXp+6PbxzPPPKMZPXq0JiAgQOPv768ZPny4xt7eXrNhwwb9tlntV79+/TSvv/662p/0oVevXho3NzfVTx2JiYmaHj16aHx8fFR/Q0JC1Pu7deumjnPv3r0s71PWybZz5szJ1ncdFham+ffff9XxX3755TRjlZk+REdHa2rWrKkpX768ZteuXWqsVq9erXn++ec1Li4umvHjxz+2D/PmzdM4OTmpMZH+BAcHa9auXasZNGiQfpvt27er/hh+RzocHBw0b775Zpq+m+Jcyck+Mvt953T87t+/r/r6zTffPHZZbpyr6ZGVz5Mb/xOZOZ/SG5/H/S+lt31m+56Z/hBiCAUvIenw9NNPqwvxo0ePTDY+PXv2fOI+5eIu28i2GfH777+rbf78888Mt/npp5/UNmvWrDFaLsLG8MdH94PXrFkzo+2WL1+ulrdr185o+apVq9TyvXv36pfp9iHixBARKSJWRMxkt19NmjQx2u7mzZtq+axZs/TLli1bppatWLHCaFsZZ7kpeOedd7K8z5wKXh3ff/+9Ev2phXxm+jB//ny1TESpIfJDL8ufJNgGDhyoqV69+mO3yY7gNcW5kpN9ZPb7zun4ZVXwmvJcTY+sfJ7c+J/IzPlkCsGb2b5npj+EGEKXBkLyiMwEc+i2eZz/7r///qseN/fr1y/DbSQITh6r9+jRw2h5165d1eM/WW+I+CMbUq1aNfXaunVro+XyyFO4du1ammP27NnTaF4+Q69evZS/sjy6zE6/Um8nbhCenp5Gx1+7dq3apxzLEC8vLzRq1Ag7d+7M8j6zw3///afcXIoUKaICEXWPbpOSktLsOzN9kLGQZe3atTPaVh7bZgbJ/HH+/HmVrkpcabKTUSQ9THGu5GQfmf2+czp+WSU3zlVDsvJ5cuN/IrfOp+z2Pa/6Q2wHCl5C0kGXJeHWrVuZGp9KlSrp/dMMmwjT1PuUTA0ZoTue/OBkhATSFStWzGjfqXnw4AF8fX2V8EqNvFcnQHWUKFHCaF5+7B633NDf1nC/GS2T/piiX0LBggWNjh8YGIi4uDi4u7urMXFwcNALzi1btuiPnZV9ZpVdu3YpEVeuXDkcOHAA0dHR6uZl0aJFep/ErPZB+p3emGY0fqkRf/CpU6eq9HkiFESIS0DksWPHcnSDZopzJSf7yOz3ndPxyyq5ca4akpXPkxv/Ezk9nzJLZvueV/0htgMFLyHpIBZHYcOGDZkaHwn+SHYRMmqSfkyHWP90FtqM0K2TIJ6MKFq0qApeMdx3aiTIQ4RxeqJF3ivpgAzJyKKclUwRst+MlsmPkSn7ZYi8R36c5UdSxkQsPWJV1X0Hly5dyvZnyiwSgCXBg/PmzVNBexL4I1y/fj3d7TPTBxkzGavUyDL5fE/CyckJU6ZMUX2QJplFzpw5gzZt2ugDxMRqJqTO/yyiI6NjmOJcyck+Mvt953T8skpunKuGZOXz5Mb/RGbOJ1OQ2b7nVX+I7UDBS0g6yCM+sRrMnDnTKPOBIZIvd9++fZkev6efflplLpB93rlzJ816WSa5eRs0aIDu3bs/dj/yQ7BixYoMt5H8vvKDkVpcy2P3iIgItd7UrFu3zmhefpzk8aREfOuEbG70S1wpJFI9vUwD2UUyBwixsbGZfo88hjUUDvJD/eeff2a7Dx06dFCfK/XjZ7FoZRWxPL/yyivq3JOsETorWPny5ZX1TITC475LSyKz37cpx89U5ORczcrnyY3/icycT6b4X8pO37PSH5J/oeAlJB1EuPz9999KqElqpN9++01ZUkSsnTt3Dm+88YZ6hC2V2DL9z2Zvr1L/SDoh8VVcvXq1EnnS1qxZoywTsm7lypWPfeQq6XnEAjx69GgsXrwY9+/fVxY5eXwuj/gESVckPm4jR45UjwHFgrd9+3YMGzYMtWrVwquvvmry712XXkxuEKS99tprysdO0gvpyI1+DRw4UN0EDBkyRH1PMhayX/nRmzx5svoRzCpSVU/SHG3btg2hoaFP3F78DUNCQvDhhx+q7cXi//zzz6vPml1krMSHVdLN7d27V30muYEQwaOzID+Ol19+GXPmzMHFixeV2BBXGjlHxFdabuYEOd/kMfAPP/yA3bt3q2PIeX/48GH1KNkSyez3ndPxM2ff0yMrnyc3/icycz6Z4n8ps33Pbn9I/oWCl5DHCDj54ZecsT/++KN6VC2P2kTcyCM0Ea/t27fP0viJRU0CLOTH66OPPlI+eeKD98EHH6j8mrJOtnkcIkREIEuOV8nFKzl4pWiF5LwcMWKE2kZyV4qQlIAW+eEQVwLZv/yQyA+k+MeZGrHMiHVahLvkoBVf1n/++cfIWp0b/ZKbA7l5eP/991VezooVK6rvTkS13LAMHz48W59nwYIFyqdavp8n5eGV/s+fP19Z3YsXL67OERkPKTSSXcRFQkSCfLcyhvI9L126VInTzCA3P3KeyliLsG3RooUaKxG2hnmg586dq85jOYb4jkv+1dR5US2JzH7fOR0/c/Y9PbLyeXLjfyKz51NO/5cy2/ec9IfkT+wkVYO5O0EIsV7Emik3A1JuWSy1hBBCiKVBCy8hhBBCCLFpKHgJIYQQQohNQ8FLCCGEEEJsGvrwEkIIIYQQm4YWXkIIIYQQYtNQ8BJCCCGEEJvG0dwdsGak3KFUx5J677lRrpQQQgghhEBV75QiJJJn+XHFmTKCgjcHiNiVhNiEEEIIIST38ff3V4VXsgoFbw4Qy65u8KUCFyHE/ERGRuorLclNqYeHh7m7RAghJIeEhYUpI6NOe2UVCt4coHNjELFLwUuIZSCll3XI/6UpBG9CQoIquSt06dIFjo68dBJCiDnIrgspr9rZQGrPS0tMTMzWoBNCrIvY2Fg8/fTTajoiIoKClxBCrAwK3mwwZswY1cS87uXlZfpvhRCSbSSYoVGjRvppS90nIYSQvIOFJ3KATvCGhobSpYEQQgghxEI1Fy28hBBigLgqxcfHc0xsAGdnZ1rkCSEKCl5CCEnO8RgYGIhHjx5xPGwEcT8pX768Er6EkPwNBS8hxKaIiopCjRo11PS5c+fg7u6eqffpxK6vr696j2EksBSZuXHjhpouV64crYZWVBjo7t27KFOmDIsDEZLPoeAlhNicpfbmzZv66cy6MejEbpEiRdJdL0JacHFxMUp9RiyXokWLKtEraeWcnJzM3R1CiBmh4CWE5Ht0PrsZWYPl0XjFihX108Q60LkyyA0LBS8h+RsK3mzAPLyE5K+E5rLc29s7z/tDzJOgnhBie9BUkQ0kB6/4Bh4+fNj03wghhOQx4p88YcKETLuAHDp0CJ9++mmu94sQQkwFBS8hhDwBEYLh4eGqZVYUWhNvvvmm8l/OrEW0bt26+Pnnn7Fjx45c7xshxDLZdn0jtt7YjJCYEFgDdGkghJBMRPxfvHhRTdevX9+mgtbOnj2Lf//9F7/88kum3yOBeyNGjFBW3nbt2uVq/wghlsm8g1/ifGww7GGHPc/vhaezJywZWngJITaFWCklLZk0U/pwurq6qmapxMXFYfny5fj666+xdOnSTBfP+Omnn9C1a1cjH2URwUOGDFEpvXSI4H/llVdw+vRpNT9w4EBs3boV165dy4VPQwixZMLjwnExNlhNV3QuZPFiV6DgJYTYFJJpQQSbtMzm4H0SYtGtVauWapZo3b1y5Qrq1aunxO7BgwcxfPhwDBgwIFPv3bJlC1q2bGm0rHr16jhy5AimT5+uz1Esorhw4cKoXbu2WiZZK0qUKIHNmzfnwicihFgyxwOPIil5upFvA1gDdGkghJB0EF/d6PhEs4yNm5NDpq3TYtnt0aMH+vfvj08++UQtW7lypZqXHLQlS5Z87Ge8dOmSqkZmiKReE3cFseKOHj0aL7zwApo3b46ZM2cabSfvO3/+fLY+IyHEejlyfZN+ulH5LrAGKHgJISQdROzW+Cjlop6XnPu4C9ydM3d5/uOPP5TonTp1qn6ZiFMRs+KSoBO8Z86cwaZNm9S2Tz31FBo3bozo6GhVlMHDwyPNfnv37q2sxrJd06ZNsWjRojQivECBAiqQjxCSvzgamJKlqmGJJrAG6NJACLEppCJazZo1VdNVR7Nl1q9fj+7duxu5WgQFBalXybwgfPDBB+jSpQv8/f3x8OFD5Ysr/r7i8iF+ySEhaaOsxQdYhHBERAQ+++wzfREHQ6Q6XXqV6Qghtot/uD/OxNxT0xWcvFDEzTquAbTwEkJsCrFsSp5s3XRO3ArE0qqr1HXy5Ck1XbdunVz345VjZ5YDBw7gtddeM1q2ceNG5WtbunRpLFu2TLk4SLCZ+OAK4pt7+/ZtNS1WXPF3Ts2wYcMQHByMDh064Msvv8Tff/+dJnOFuDNMnDgxm5+SEGKNLDr4ld5/t1uFp2EtUPBmA1ZaI8T2kcf3OrcCjcYB1StXUNMFXJ0tpoKXWHJFuJ48eVK/TKy44mv77bffqnmZnjFjhl7sCo6OjihbtqyaFv/fDRs2GO138uTJ2L59O/bv36/cIpo0aaKKTcirjqNHj+rdIwgh+YO7EXex+rY2/7Y77PF8feObbUuGLg3ZgJXWCMlfiMCVR/fSLEXs6kSnm5ubSg02aNAgjB8/Hg0aNECfPn3w0ksvqW1EDLdp0ybDfUhGB9nP5cuX1fz333+P+fPnKxFcqlQpNGrUCE8//TTeffddo/ctWbJEBcbRpYGQ/MOcPR8hDtonZwMr9IKXixesBQpeQgixUkSoStUzCUaTV/Gz/e2331RuXR3ix6srmiHExsbqXT6EYsWKqbLCX3zxhfLb9fLyUhXUxAdax+zZs5WAliA3nWX5zz//NAqUI4TYNqeDTmLtvQNq2svOEUObvgVrgi4NhBDyBMQXWBcAJ4FelmLlPXbsmLLoipX1nXfeSXebjz76SFlon332WZWR4dSpU/jhhx+MtpGgttWrVytXB51lOHX6McPUZRKsJr7BFSpo3TwIIbZNQlICPtme4q8/qvYIFHQuCGuCFl5CCHkCugAtaTJtSRZeEbyPQ0oA79y5Ew0bNlQBaP/995+RL64uvdiLL76YaSFfpUoVdOzYMUd9J4RYD8tOL8T5GG32lyrORTCw7nBYG7TwEkJsChFtuoAsU1pi00vLZW7EdUHy5D4JXallQgjJKvej7uP7kylPhT5sPxOO9tYnH62vx4QQ8hjE5eDGjRsmHSNJQ1anTh2LG3fJrUsIIbnJV7veQ4QmQU0/V7wF6hVvZJUDTpcGQgghhBCShgMBe7BBF6gGR0xo+wWsFQpeQgghhBBiRFxiHD7bNVk/P7HhBHi7esNaoeAlhNgUkjpL/Fql6dJo5RQJVLty5YpqlhS0RgghucXCw1/jRnyomq7rXgq9a6bN4GJN0IeXEGJTiCA9cuSIftpUackkFZdumhBCbJlrIVcx/+IfaloKnX/Y4VvY21m3jZSClxBCzJT5gRBCLI0kTRKmbB2L+OT5wRWfRdUi1WDtWLdcJ4SQPMDe3h5FixZVTaZtkfv372P9+vWwRP79919V3Y0QkvssPfUrTkQGqOkyjp4Y3ew9mxh2WngJIYRg4sSJqFixInr06KEfjX379uHWrVtq2snJSVVWq1+/fp6P1uHDh/H777+rcsaEkNzjbsRdzD4xVz8/td0suDq62sSQU/ASQsgTEL/dmJgYNe3q6mpzbg2nT5/G33//jTt37hgtHz9+vCqpXLt2bfW6bds2tGvXDv/73/9UbuK8YsKECShRooQqi2yJ+ZAJsZXr3MfbJiAKiWq+b8m2aFyqOWwF23w2l8vMnTtXVS3KTIUjQoj1I8FvZ8+eVc2SszSEhITg3LlzePDgQZavab1794aXl5d+WXx8vBLCIjaXLl2KNWvWYO3atcrtYdOmTSbt9+bNm7F//36jZcePH8eqVavUeEu/evbsiR9+SKn2RAgxLesv/409IefUtK+9Cya2nW5TQ0zBmw3GjBmjflTkMRshxPLw8fFRzZQ4OjqqZonExsaq61KxYsXQokUL9Tpr1qxMv19EbIcOHYyWnTlzRu1XrLs6dNPBwcEm7D1w/vx55UoRFham5o8ePYo2bdogIiJC7zMt/Vu3bp1Jj0sI0fIw5iG+PPBZ8hzwfotp8HT2hC1BwUsIsSk8PDxUAJY0mc42kn4sLlI1h8QY1KtRWTWZ1i3PtZbF1GfPP/88Ll++jLt37yor73vvvYcPPvgAkZGRT3yvBIMFBASop1aGHDt2TLlu1KxZU79s48aNSvS3atUKpmTUqFEoWLAgvvnmG1y/fl2J3/fffx8vv/yykdi+ffu2+oyEENPyxfa38EijzcvQuUhddKiY4stvK1imuYIQQsxNfBTweUnzHPu9O4Bz5sT6li1bsHXrVly9ehVFihRRy1599VV88skn6kmUzvVK55aRkJCA6tWrK19kQ2utt7dxBSWxsoorgVh/ExMTlf/sokWL8NNPP6ngtdSINfZJFtjy5cujadOmaZY7Oztj2rRpymdYAtP69euHyZNTKjwZ9k/6K/68hBDTsOvGFmwIOqSmC9o54t2O39rk0FLwEkKIFbNixQplETV04YiLi1OvYjUVtm/frkSwu7u7Epf37t3D8uXLlaW2QIECapvU1mARvLLP1atXq3USsNa3b1+88sor6fZDtpFtH0fbtm3TFbxC165dVWCcp6cnZs+ene7+BVlPCDENEXER+HhPStqxtxu/BR8307qDWQoUvIQQm0LKCXfr1k1Nb9iwAW5ubtnbkZO71tKabB29eVObnqts2TK5n4tXjp1Jdu/ejYEDBxot27t3r/LjrVy5Mi5evKhcHlauXKl3RRAr6siRI5XFt2TJkkr0iitBw4YN1XqxAotF97PPPlPpygSxIj/11FMYMmQI2rdvn6YfcjwJbssOInSfeeYZ5VYhgXL+/v76Qh86pH/iolKqVKlsHYMQkpZvd3+Ae4naEuwtClZGr2rPw1ahDy8hxKYQcbpz507VcpRRQVKPiVuBswc0Tu54EB6tmkzrluday2TaM7F6iqB9+PChUXYFsZCKJVaE+VtvvYUpU6YY+d0+/fTTuHDhghof8cmVADERyTpECEsatiZNmuiXdezYUfnRLl68GKZE3CUGDBig+iGZGiTP79SpU9NsJ/0TC7HkAyaE5Jyjdw9iWcBWNe0Ge3zUaY7NpVw0hIKXEEKegPwIlC5dWjVL+kE4ceKE6o+kC/vtt9+URbt79+4qn6aIXHn977//0KdPH6P3idiVz6KzVA8fPly5Roj41AWsSZ7dBg0aGL1PXBokB6/OZcJUAWtXrlxRac/EGi++vEuWLFH+xzpEmIuFetiwYSY7LiH5mdjEWEzdpn16I4yrMwqlCtj20xMKXkIIedKF0t5ePbKXZkmlhcXPtmrVqiqYbOHChXj77beVW8COHTv0BTLEZ1fSi+kQESwW1BEjRuiX9erVS5VNFlEpyHtef/115fNryKBBg9ClSxflXmAKpJKbWKlFqBcuXFgtk/2PGzfOKC+vFMWQgDzpJyEk5/y471PcSNCmAazjXgqD6qZcD2wVO41c/Ui2kJyREsUcGhqqDw4hhJgXEVC6QCzJHJCZ1GTy+F5EnGQR0GUvsAbEn1assmIRzQjx1b106RLeeOMNJXwly4K4BYil1tA94MCBA/j333/x8ccfw9IQgd65c2eVYzgrWOv3Skhucvb+Kbyw/gUk2mkDuZb3XIXKhavYvOZi0JoVkKRJwqG7h/DHub/xcat34e1qnD6IEJK7iF1A9xhfrJ+W4tYgFl7JvvA45syZo9qvv/6q/GTFSjt48OA0pYGbNWummiWSnk8vISTrxCXG4YMt45TYFUZVH2wVYtcUUPBaAd8d/RG/np2npldcqIkR9VKSsRNCch/xIZXsAYIEVaUWi+ZCUnlJexwi0N98803VCCH5m/kHv8CVOG2Qa3VXX7zaaALyC5bjjEYypGellB+0ZRf+5kgR8gTE9zS1/2lOEd9dS/LfFb766itVRIIQQp7E+eCz+PXSCr2185On5sLJPv9kPaGF1wqoWKgiirtUQWDsJQTFXsPFhxdRtXBVc3eLEItEfHYzU1I3K6SXsYAQQqyF+MR4fLg1xZVhWJWBqFqkGvITlmWuIBkyqMZz+ulvDv/MkSKEEEJIpvjlyNe4GHNfTVd2LowRTd7OdyNHwWsl9KnWE47QVozaG7gJewJSUvYQQgghhKTHpYcX8dOF39W0g0ZcGb6Hk0P+cWXQQcFrJRR0Lohx9VOcyydufwcPoh+YtU+EWCKSiqpHjx6qybSpgtZu3LihWo6qtxFCSB6SkJSAD7eMRULy/KuVnkPNorXz5XeQ7wWv1IufMWMGfv/9d5P9OOYWQ2oPRIUC9dR0dFIIRm6ciMQkbWUkQogWyUsr+WSl6SqHmSItWXBwsGpMXU4IsRYWHZuDc9GBarqiUyGMav4+8iv5WvD+8ccfKoflgwcP8OOPP1p8FR97O3v82v0buNgVUvMXw45hxsE55u4WITaP5N0tVaqUapaSg5cQQh7HlZAr+OHsQjVtL64MHb+Ds4Nzvh20fJ2loWHDhjh48KCKwJaobimtKdYbS/5B83HzweyOX2HU5hFSJw9/XvoVtX2r4emKj8/FSQjJPpKOrESJEhxCQojVuDJ8tHUc4qEtpvty+R6oXaw+8jNWbeGVUphSWlOqAx07dizdbVatWoVnnnkGHTp0wIcffojw8HD9umrVqukTyP/zzz/o37+/RYtdHS1LNcPQmmP08+/veR9n7581a58IIYQQYhksOTEfpyMD1HQ5R0+MbslqhVYreN9//32MHz9eJV0XK63UWE7Nzz//jBdeeAEdO3ZU265bt04FsqT2wVu6dClWrlyJefO01cysgfGNRqBFsS5qOglxeHXjaNyP0qYcIYSYFrlmxMfHq0YfXkKIJXM99Dq+Pz1fTduJK0OHb+Hq6Ir8jtUK3rffflsJXRG06SHBKh988AEmT56M119/XVl5V6xYgd27d6tgFh2zZs3CmjVr1DpXV+s5IcQS/V2nz+Dnpq2yFJX0EIPXv4aYBMsOvCPEGpHMDCdPnlTN0rI03Lp1C59//rm6qZeb9qioKLU8Ojoaw4YNw82bN422lxLD8nRMx8iRI3H06FEsXrwYEydOxCeffIJ79+4hNjYW8+fPx7hx4/D111+r/RFCLBsJZP9oy1jEJbsyvFi2C+qVaGLublkEVit4vby8Hrte6t4HBQWhZ8+e+mWVKlVCjRo1sGXLFjX/3Xff4dNPP0W5cuUwbdo0JZAfl6lBfgDEkmzYzImLgwuW9PwB7vY+aj4g6iJe3/IuLVCE5BNEzNapUwcXL15E5cqVcfbsWbRr105/vfr1119x//79NMG6V65c0c/LNt27d8fOnTtRpkwZrF69Gi1btkTnzp1x5MgRVKxYEQsXLkS/fv3y/PMRQrLGn6d+xomIW2q6jEMBjGv9CYfQ1oPWdFaNkiVLGi2Xed06cYcQa4chj/PhnT59uhLGlhbEtrDbj3h+3YtIsovF/ntb8OXB7zC52Xhzd40Qs5UWNrXbgfj6N2rUCJaG3LxLMJ1YZ3UEBGj99rKCiNnvv/9eTYvQrVmzJp599ll89dVXalmLFi3QtGlTZUTw9fU14ScghJiKW2G38N3JH/Xz09p/DTdHbcEqYsOCV3ztBBcXF6Plbm5u+nWdOnVSLbO8++676pGfDrHwli5dGuamhk81fN56OibvflM57Pxx8ReU9yqNAdVTyhETQrLOgHUDEBwdbJYb2WVPL3vidnLTfu3aNcyZMwd9+/ZV4tfPzy/Lx5M4B8MnYYIE+qZedvv2bQpeQiyQJE0SPtoyBjHQulw979cRjUo1N3e3LAqbFbyFCxdWrw8fPtRPC5JzVx79ZQcRz6kFtKXQo2InXA15HT+fna3mPz34MfwKlkBLnvCEZBsRu0FRQRY7gmJ5lfgD8d197733lEvCG2+8oXx3s4IYAgxTsGW0zFSFPAghpmXpqV9xNPyGmi7l4IEJbT7nEOcXwVu3bl31GPLw4cN664T450plNUk/lhPmzp2rmqVd/Mc1HIobof7YHPA3YJeIsVsmYEWvP1DJW/v5CckPyP/5Sy+9pKaXLFmSo2BUsbTq0P2/61IZ5iaGx30SUjBHmjy5kmwzgwYNQr169VS8ghAXF6ffVgLuzB17QAgxLQHhAfj2hNYlSZjWbibcndw5zPlF8BYpUkRlZpg5c6b6MRC/PsnIIL59AwYMyNG+x4wZo5r8cDwpeC4vEf/jGe0/xAtrbuNc6EEkIAqD14/Amj7Ls/QDSog1I8JUhJ+waNGiHO1L51Yg+zx+/Liarl+/fp6I3sywd+9eFXQrFeCcnJxU8Jmjo6N6kuXu7q5iFvbt26cswboUjMy2QIjtIJpm6pYxiE52Zehfqj2a+rUyd7csEqsVvOvXr1fpc3TWi9GjR6NgwYLqUZ7ucZ485pPAC/FrEwEsAlUu+MWLF4et4mjviIU9vkPPVc8jKPYawhPv46V1I/H3s7/TeZ2QHNxM6iqtWVJxGrHqtm/fXvntirjds2eP8r2VZcLHH3+sro0ijEXohoaGwtPT09zdJoSYiBVnFuNg2DU1XcLBHRPbTufYZoCdxkqzqEuqnatXr6ZZLhf+1EEbEtQhFdakspopfXB1Fl75ERGxbUmI3+HTq/ojOumBmm9QpBUWdP8eDvaWYZkiJLeQMuEFChRQ0xEREerpTmbcIK5fv47y5ctbVT5uXd/379+PwMBAFZ+QOpvEhQsXcOLECXVdlKqUy5cvVxkXJN2YsGDBApWZQXfdlJ8ESVUmRXp0Il8MC7/99ht69+4NHx/reVpkzd8rIU/ibsRdPLuqKyKTrbvzO3yPFqXb2uzAheVQc1mt4LUELFnwChceXMTAdS8iEdrcwr3KD8Rnbd43d7cIyVXym+AlGcPvldgqIt1Gre6DfWGX1fxzJdtgWqe5sGXCcqi5rLbwhDmRgDUJCGncuDEsmWpFquLrdl8DGu3XvOb6Uvxy8jdzd4sQq/xxET9eabQREELMzcqzi/Vi19feDZPafmHuLlk8FLzZQALWzp07pzJAWDodyrbGxAbv6udnH5+JTde3mrVPhFgbkt1AgtakWVppYUJI/uJ2xG3MPPqNfn5a2y/h6Uzf/CdBwZsPeKXOQDxXYbB2xk6Dt3e9jTP3z5q7W4QQQgjJYoGJKZtHIyrZb7dPyTZoVUYbpEoeDwVvPmFKqzfRyEdbOSkJcXhl40h1l0iIrSHpuMR3V5pMmwIpvCDpyKTpijAQQkhes+z0Qn1WhuIO4srwJb+ETMIrtw378Bpib2ePeV1noLR7TTUfkxSKF9YOQ2hsqLm7RohJkbRhEqgmzVQpxGQ/kntXmiWlJSOE5B/8w/zxzfHv9PMft/0KBZy1AbrkyVDw2rgPryEuDi74s+c8eDmWVPMP4gIweP0oxCbGmrtrhBBCCHmMK8MHW0brC0wMKNUBzW04BVluQMGbzyjkWgh/9fwFztCm9LgWfgaj/5uk/pkIsQViY2MxZMgQ1WTaFEig2u3bt1Vj0BohJK/589QvOBZ+Q02XYoGJbEHBmw8pXbA0FnSbB3uNtgjHoaAdmLKbKU2IbZCQkIDFixerJtOmQFKR3b17VzWmJSOE5CU3Qm9g9omUHLuftPsa7k6miU/IT1Dw5lPq+tbGjDZf6XP0rr7+F+YfX2jubhFikYjfrq+vr2qW5sP7999/4969e7Bk/v33X9y6dcvc3SDE6khMSsQHW8YgJtmVYZDfU2js19Lc3bJKKHjzMV0qtDfK0fv9yW+w7uoGs/aJEEtEMjOUKVNGNUvL0tC/f3+VH9iSGTFiBHbt2mXubhBidSw5OR8nI7Q3i2UcPDC+zWfm7pLVYllXbivBGrM0PC5Hb7+Kr2pn7DR4b897OHz3iLm7RQghhORrrj26hjmn5qlpOw3wSftv6cqQAyh481GWhoz4sOUEtPDtqqY1SMDIzWPVPxohxHq4c+cO/vvvPxw9ejTDOvSyfuPGjQgJCTFaFx8fj5UrV6ZZvm7dOiNXBJ37hLTNmzfjyJEj6fo0BwYGYv369Th16pQqx5wa6YccT/Z34MABREdHp9lGdyw5vvRDrrk7duzAyZMn02y7ffv2dJcTYq0kJCXg/S1jEAft/9dLZbugQalm5u6WVeNoyp3JxXLt2rXYs2ePimbWPQZs27YtevToofJiEstDfBK/7/I5+q++jyvhRxGvicSL64fjf88uRVH3oubuHiFmR0Sbzm1Aik9IPl5LYs6cOUrwVa9eXd2Id+zYEcuXL9f3UwTjCy+8gCpVqsDR0RGnT5/GL7/8goEDB6r1kZGR6Nevn3pvo0aN9PsdNmwYZs6ciRdffFHvPtG9e3ecPXtW7Uu2b9myJf73v//p37NixQoMHjwY9erVU0La1dUVUVFRRv0VMazLeHHp0iWEh4erfch7dMixunbtijNnzqBu3bqq/3Jc2e7EiRNGvzvdunVTn1e2I8QWWHR8Ls5EBqjpco4FMK71p+buktVjEgvv/fv3MXr0aPj5+WHSpEnw9/eHj48PChUqpC5mI0eORKlSpfDOO+8oKwOxPJzsnfB7z7nwcS6v5sMTgvDiuhGIjI80d9cIMSsiBqWJFVKabl5aTExMutum11JbMTPaLjtcu3ZNCUOxuoow37JlC/744w+1Tq65r776Kt58800lUPfv34+PP/5Y+dUGBQVl+VgiTsXaumHDBhw6dEgFpInlVXes1157DZ9++qk6jliAW7duncZyPHv2bL2FV/rdt29fjB8/Pt3fFjmWiFwRwPI5RKwfO3ZMv82ff/6pfmtEiBNiC1x6eBFzz/yqpu01wKcd58DV0dXc3bJ6cix45YJfs2ZNlf5HHivJBVQuhJISaMmSJeoC/PDhQ6xevVrd0Yt1hFgmHk4eWNbrF7jZ+aj5O9FXMGzD64hPijd31wjJNFJOWK5D0kxRWrhAgQLw8vJCmzZtVJNpWSatT58+RttKFgfdutRNrJCGlCtXLt3tsoMYFUT0CeXLl8eAAQPw119/qXkRv6GhoXjrrbf0248bN05Zf8XSmlWGDx8OFxdtSsMKFSqoz3HhwgU1L9d7sebK/nVMnjw53cwW4qog269atQre3t5KPKd2jxBRbvgdli1bFp06dcKCBQv0y2RaLMpiuSbE2pHf2w+2jENCsivDkPJPo27xlKcuJPvk+AohFxm5UMlFLyPkYteuXTvVLl++nNNDklzE18MXf/T8Bf3WDEIionAm5BAmbfsQ33acbnHpmAhJDzlPixbNX644InINESG6d+9eNX3jxg2UKFECbm5u+vVOTk5KPMq6rFK4cGGjeRG/Okv3zZs31dM8Z2dn/Xq5QTB8j4haEc1Lly5V7hOyTgS57EMsxLK9jpIltVUhDRE3CxHCs2bNwsWLF5W1V2fNJsTa+eXItzgffVdNV3TywuhWU83dJZvBJIL3cWI3NZUrV4YtZGmQll4whi1Q2bsifuj4PUZuGQHYJWDb7fWYeagk3mr6urm7RkieExERkeG61L68j3MRSJ3OLDtiMyMePXqUZl4nMosUKZJmvSBP3mSdYd9SV5FL7bLxJOSYqY8l+zR0ZRP3BxGoYvwQNzhh06ZN2LZtWxoLb3o32c8884xyofvnn3+U20SLFi1QrVq1LPWTEEvkwoPz+On8b2raQQN89tRcuDhon6YQC3BpOH/+vIqyFXbv3o0ffvhBXUhtGVvL0pAeLfwaY2qzFCf53y78jL/OrTBrnwjJDFJOWP5HpZmitLAE24p1VESbNJmWZdIkICv1thk1Qwvr47bNDoZBYyIw16xZo4LJhObNmyu/W3E50yG+tRJrIWJRKFiwoHKnuHr1qn4bCYITy2tWkGPJ9X/fvn36ZeI2IcFrOqRanVhxxRKsQ3x5M4tYp19++WXMmzdPCWfx6yXE2olPjFdZGXS1IYdWeg41fRmEaTEWXrlIjRo1Sj0+a9Kkibqg1q5dW/nuyp03sW76VOsB/7C7+PX8bDX/+aFPUaJAMbQr08bcXSMkQySeQG68hRkzZuj9TXOCWB4lBkHnp2tp7Ny5E6+88ooKEJMsCQ8ePFBBaoJkUxg7dqwK+nr33XfVU7kvvvhCiUbDjAySBUEXWCwBdj/99FOWx65q1apKgIpvs/juynfxzTffGO1HsvaIn++QIUNUNgnpu/jxZgVd9gi5QRB/ZUKsnfmHZuBSzH01XcW5MEY1/8DcXbI5cmThXbhwoUqHI+JWgtIkMlciZiUYQXy5iPUzockwdPHrp52xS8KE7RNxNvisubtFSJ4ij9Yl84w0S/NlF3EpVlRJySVP2aQojsRVGPoxf/vtt+parctwMH36dPz6qzYKXIeslyw7cj0PDg5WKSZfeukl5etreKzixYsbva9z585Grmo//vgjPvroI/UETPYjOXdFXOv2I5ZdeSooQXbiyiAGEwlek30b+v6mdyxDYS2fU1KpZTfQjxBL4ez90/jl0lI17ShZGTr9ACcHJ3N3y+aw06SXNTyTyAVJLmS9evVCs2bN1EVVXiVrg9yx27pflS7AQh77ySNBWyVJk4TBa8fhZIi2NKibfSH83fsv+Hlq/e8IsSQktZdOBIn/bWbcBMRX9fr16yr4K7WbArE8xKAiQllyvosbRUbweyWWTmxiLAau6IwrsVpX0NFVBuK15u+bu1s2qblyZOGVNDfy6FB8xiT1jZTaFZ9e+bGRO3BiG9jb2ePXHl/Dz62Gmo9OeoQX1w3Ho5i0gTCEEJKbNzPLli1TbhPiGvE4sUuINTB336d6sVvdpSiGNX3b3F2yWXIkeMWaK0EDInhF5ErEslyQJHrW0h77kZwhkaJLn/kJBR20gSYP4gLw8r+jEJOQtShuQgjJLhJ8JwUr6tSpo88zTIi1cvTuYSy6ulpNOylXhh9VEShioVkaJK2NLuG3BDpIupj0cicS68fLxQtLe/0CZ2jzZF4LP4tRm95QNb8JsWUkBaH4vkqz1XSE1oD49EpQngTCFStWzNzdISTbSBXT97eNhybZNjiu5iuoUoRPxi2+tLAOicSVMpFxcXGwZSQHrwRMiAtHfqN0QT8s6DYf9hqtn+PR4D14d+e0NPkzCbE15ElW6jy1hBCSHWbu/gC3E8LVdAP3UhjcMG1pbWLBgleSlz/99NNpon9tjfyQh/dx1PWtiW/afQNotEn3N95aja8Pf2/ubhGikHy3EoAmLXXu25xc2yTlorTUBSQIISQr7Lq1HSv9t6hpN9jh0y4/wcHeuIgNMT0mLT4uSd4l76K4NSxevFgFrkmScB0S/fz99xRGtkCHcq3wXuNp+PyINlfgovM/oXgBX7xQgzkxiXkRQZqV6o+ZQWISTJHPlxCSv5Fg76m73tHPv1XvdZQuWMasfcovmFTwCpJHUZcIXASwYaUjEcPEdni+5jO4GxGEhRe+U/NfHPoMvu4+6FSuo7m7RgghhFgcn+18C/cTo9V0q4KV0LfOUHN3Kd9gUsErFpClS7XJk0n+YGLT4bgbGYSN/kslqzMm7XwLC91+QYNiDczdNZJPkRiC99/X5rH87LPPjIoZZBfx3b1/X1sFSQo60K2BEJJVNlxZg42BB9R0QThgWud5zGiVh9AZjeSYL9u/iwaFtVbdJMRj+KYxuProKkeWmIX4+HhVdlaaTJsCCcr09/dXzVYCNCWFZFYzTmTnPYQQICgqCJ/um6ofig+avAtfD2YasWrBe+PGDfTt2xd+fn4qN69hy6hMJLH+whS/dP8K5dzrq/k4TQQGrx+OwMhAc3eNEJP58BYuXFg1W8kxLiV+JWd6br+HkPyO3CR/tG0CwjTaG/CuhWujW3XGu1i9D++LL76oClDIo0QpAWd0sOR8vcT2kLrfS3v/iB4rBuFB/DWEJdzHS+uHY+Uzv6v8vYRYM+LCIKVsbQkxQmT1mpyd9xCS31l54S/sfXBaTRe1c8L7nX4wd5fyJSa9csmjrkOHDiEoKAiFChUy5a6JFeDh5IGVvX9Fj5UDEKUJQmD0Dby6YTT+7LlAVWojhJgecTOQDDhiaJBrsLwaIsHC6YnUixcvqvdldj/ZeU9Gx46IiIC7u7uRL3RUVJTyt9Ztn919E2JJ+If546vDM/TzU1t9ikKu1EdW79IgFySpvGaKIBFinfi4++CvXr/CUeOp5i+FnsLYzZOQmES/P0JyA3mSNnnyZNSvX19ZYOUaPH/+fGzZsgXVq1dXorFMmTL477//HuueIPuRJ3NNmjRR+/Hx8UmTRjK992T12I8ePYKnp6eqWmeIWNANg56z+7kIsRTkd++DrWMRrdH+/vX1bYY2Fbqbu1v5FpP78A4ePBhTp0616YpE+bnSWmaoUKgcfunyI+w0WqvugXs78NGeT20m2IfkL8TSGBYWhuPHj+PEiRPK2iiZIGS5YdpF3bbSDK9/Ejgny2JiYjK1bXZYuXKlyn0ultNJkyap4jgTJ05Uy6WPL730El5++eUn7l+KBs2bN0/1S65z48ePx/nz5/Pk2Hm9b0Jym99OzsexsGtqupS9GyY99S0H3ZoFb3R0NBo1aqRvGzZswFdffaXqnDds2NBoXZs2bWAL5PdKa5mhYYm6mNF6JqDRnmJrrq/E98fnmbtbhGQZsS6KtTE4OFifS1yucbJ87NixRtv6+vqq5bdu3dIvE+Eoy4YONc63KcUxZLmhoFy0aFG2vqG33noLderUUU/ZRo4cqUT5G2+8gZo1a+qXBQYG4ubNm4/dzzvvvIMGDRooVwPJp16yZEkcPHgwT46d1/smJDe5HHIZc05pf/PsNMBn7b9Rbn/EfOTYAUp8qCQrQ2agq0P+omvFdgiM+BCzTkxT8z+d/gHFPHzRv2ofc3eN2DBSTvjMmTP6aVMhlSMtNQdv+fLl9dMFCxZUr4bV5nTLxJ0gs/sRROhn5T05OXZe75uQ3CI+MR7vbR6NeGifag4p2xUN/VpywK1d8ErpYPGzIiQ9htTtizsRQfjryo9q/pMDH8PXzQftyrTlgJFcQQSpWABNhTxOFyTISpeSTCyPEyZMSBM0JQG7qYW2PBEaPnx4mqArSeGYetshQ4Zkq4/ppUrLTvo0U73ncfvJaF16bnCm+lyE5CU/Hv4KF6K1aTkrORbEmDaf8guwAHIlxFV8q8TH6sKFC+oiX6tWLfTu3ZsRtfmUd1u8hsDIe9h+929VmmLC9on4rftC1Clax9xdI+SJeHh4pPu0Kr0nVultK0YBaZnd1tYRNw75nA8ePNAvE3cRaYRYOyeCTuDXi3+paUcN8HnnecxSZCGY/NmcPEqUR3/iW7V+/XoV0SuBBeKHZejXRvIPYpH5ttMU1PLS+nAnIg5DN47C9dDr5u4asUEkoEwCZ6XJtCnQlRaWZssBuXmBGEFat26t/KDF91YMIxJ4xqBWYu1ExkfivS1jobtCjKoyENWL1jZzr0iuCd7Ro0ejVatWuH37tko7c/LkSSV0xe9KHgOS/FuNbXHPb1DKVfvPH5MUjsHrh6lyi4SYEonYnzZtmmqmLC0s4kyapQmz1MUg5AZTrMeGLhTpLUv9vvSKSogbh6El+0nvyeyxJRuE7Ldp06aqWFH//v1VWjJDC3d2902IuZix+wP4x4eq6bquxTC02Tv8MiwIO40Jr94SQSu5EeXRVOoqa5cvX1ZC+N69e7AVJFWRfM7Q0FB98AR5wpjFhqH7ikEITdRGVft5VMTyXkvg6azN20tITpGUWiKWdP636bkOpEZShl2/fl0FSRkWVtAhVt1r17TphUSYWWLgGsn690qIqdh6YzMm7Jyopt1hh5W916K0V1kOsAVpLpNeteVuW1rq3JS69GWsikMKuhTEqt4L4AIfNRgBkVcxdONoxCamPWcIsRRE4FaqVEk1il1CiCHB0cGYtvtd/fzk+uMpdi0Qkwpe+SHo3LmzKj6hi0AWJGetRCl369bNlIcjVkqxAr74o+cvcNBorXDnQ05g3JY3kZCkzXFKCCGEWAPykPzDreMRkqQ12nTwqoretV81d7dIOpj8uZxU6ZGciPIISUpBFipUSKUIEjP0jBkp9aRJ/qZq4YqY10mqsWn9A/cH7sQHe6ZZnH8kIYQQkhHLzv2OPQ9OqWkfOGJql5+YOi+/pCXz8/PDgQMHsHfvXhV9K1ZfSUvGMrwkNc1K1cP0ll9j8t7xgF0i1l9fjaJuRfBm4wkcLGJRSHzC2bNn1bSuyhchJH9zLfQaZh2ZqZ//pPXn8HYrbNY+kTzOwyu0bNlSiVwRvPTdJRnRo3JbBEdNxczjH6n6i4vO/YoiboUxpNZgDhqxKEyV4owQYhvV1CZvHoOY5CRkA4u3QqsKdNvMVy4NYgn57rvvVHCHVBCSyFixiCxevNjUhyI2wst1e2Nw1RSr7qyjX2HtlXVm7ROxXuSac+jQIdWyGpmfkUuN3LhXr15dNQatWQ90kSK5xY9Hv8H5yAA1Xd7BAxM7fs3BtnBMbuGdNGkSlixZospuNmzYUAng/fv3Y+zYsarspi3k4p07d65q8tmIaXir+asIinyAjbd/U/Pv7/0AhVy90NqvNYeYZAlxN8iqC5Uu/2tUVJRRqd/U+V6JdVrl6YJCTMmxe8fw6/kl+mpqXzw1F26Oaa8bxIbz8EquSsl/uXXrVjRv3txo3d9//40333xT5US0FZiH17TIqfjqundx5OF6Ne9o54yFXX9FPd96Jj4SIWm5e/euCrj19fVVBRdE5BLrRX6P7ty5o25mypQpw++TmISIuAj0XdUNt+MeqfnxFftgWKupHF0r0Fwmt/DKnXSjRo3SLJeKOny8RB6HCIxfenyGvn+H4ErkPiRo4jD8v9fwV48lqORdiYNHMm3Vmz17tpoeP368UaWwx1G8eHH1Kk+iUiPXLrH+ChTD1oO4n1DsElPyxZ4P9WK3gbMPXmnxIQc4P1p4hU6dOuGFF17AkCFDjJZPnz4d58+fx2+/aR9Z2wK08OYOcYlx6L7sZdyLP6PmvZx8sKLXnyhRoEQuHZHk90prhoirUuqSxCJ2GzRooKalZLqIXmL5yM0Ofa6Jqfjv+ka8uUvrlumhscPKZ9fAz6scBzi/WHjlh+HLL7/Uz5crVw6vvvoq/vzzT/UDIT8ekqZsz549ePvtt3N6OJIPcHZwxj99fkaXZYMQrrmO0PhgvLxhGJb1/B3ert7m7h6xceQpVWqfT7ELVK5cWU3rgnEJIfmHe5H38PGeD/Tz79YbR7Gb3yy8UjK4ffv2mdpWrCLbtm2DrUALb+5yL/I+eqx4HrF299R8Ja/q+KPHIrg70bpGcs/CSwghhiQmJWLEuudxKOS8mu/kVQWznllJv3Ar01wmd2nIT1Dw5j5XHt5C3/8NQqJ9qJpv6NsUP3f+EU4O2qh6QlJDwUsIMSW/HP8Bs0/9qKaL2TlhVf9t8HItxEG2Ms1lb6ofmBo1aiiXhV27djFdFzEZlQqXwc9d5sMuSZvy5WjQQby1810kabTJvgkhhJDc4tT9U/j+1Dw1bafRYHrbmRS7VopJBK+4Knz66acIDg5G//79UbRoUQwaNEj58YaEhJjiECQf07hkTXzVejag0Vp1t/pvwqf7pzPrB8kzJGhNCuhI02VrIITYfgqyd7aOQyK0D8KHlemGxmU7mLtbxJyCV9JJPffcc1iwYIHKZblx40ZVaW3mzJkqp2WbNm0wY8YMnDt3zhSHI/mQLpWaY3KDzwGN9pRdcXkp5p2cb+5ukXyCeH7J9UsavcAIyR98vudDBMQ+VNN1nArjtXafm7tLxJJKC4v4bdKkCT7++GOVvufmzZt48cUXVZYGqX5UsWJFVYCCkKzyQp2uGFrtHf38DyfnYvnF5RxIYoRkUNi+fbtqpsqmkBv7JIRYLuuurMVa/y1q2kOqqXVbCCd7xo5YM3katBYTE6OyNEgltlmzZsHaYdCaeXh/2/dY46+z7tphVtuZ6Fyus5l6QwghxJbwD/dHv9XPIDJJm4/7i3oT0KPuUHN3K98TZqlZGgIDA1WZTkMkAXiVKlVgK1Dwmo8R66Zi/4NVatoBjvih01y0KNnCjD0ihBBi7cQnxWPImn44FXpVzff0qo7Pe/NJoiVgEVkaDBHXhfLly6NEiRKoXr26UdNVKiIkp8zvMQVVPbTBA4lIwNitr+NE0AkOLFHFcObOnata6opp2SUhIQGrV69WTaYJIbbJj8fm6MWuH5zxfvcF5u4SMREmt/BWrVoVHTp0wNixY5UST23hLVmyJGwFWnjNS3xiPJ5eNgp34g+peTeHAljSfRGqFq5q5p4RW8vDy9y+hNg+hwMPY+imV1VOBkeNBr91nIfapVuZu1vEUkoLGyJlhK9fv47Zs2erGuaE5CZSfOJ//X5Ap79exiPNWUQnRmDophH4s8cSlClYhoNPTIbcrLdo0UI/TQixLUJjQzF5+xvJCciAMeV6UuzaGCa9ckv9+VKlSqXx3SUkt3B1csGafj/DPamCmg+Ne4hXNg5Tdc8JMRVubm7Yu3evajJNCLEd5EH3lJ3vIChOW9GziVMRvNLmU3N3i5gYk5sq3n33XQwdOhTXrl2z+HyVsbGxuHLlimoPHjwwd3dINvF288TffRbAKVHrLhMUfRdDNw3HoxjeeBFCCHk8f57/A1vv7lXTXho7fP70EjjYO3DYbAyTCl7xlxs5ciTWrVun8u3Koz/Jy6trOr86S+Hy5cvo2rUrmjdvjq+++src3SE5oFTBIvir5wLYJxZR8zfDr2PEf6MQGR/JcSWEEJIuZx+cxazDKb//nzR+F8UKluZo2SAm9eGVR32bN2/O+GCOJj1cjqlVq5ay7n777bcqjRqxbqoWLYWfO/2MoZuHAA5hOB9yFmO2jMP8zj/CxcHF3N0jVkx0dLSqGCns2rWLbg2E2ADhceGYtGUs4pGk5l8q2gTtaz5v7m6RXMLR1D68Tz31FPKSHTt24MKFC+jVq1e6GSDCw8OxZcsW9SqW3MqVK+dp/0je0qR0ZcxsNReT9owAHKJxNOgw3twxCd+2/waO9pZ1w0Wsh6SkJBw5ckQ/TQixbsTlcuru9xAQE6zma9sXwBtd5pm7WyQXyRUFcOPGDZUDU4SoiGCxpI4bNw7FihUz2THWrl2Lt99+W6UcOnr0KKpVq5ZG8ErdexHgsrx06dJ47bXXVMljlja2bbpUqYeQmK/x6dHxsLOPw86AHfhw74f4rNVnsLdjhL2t4+LiotyqdNOWuk9CiPlYfnEZ/gvYoaY9NcCM7otV5h9iu5j813/Tpk1KfG7cuBFFixZVOdOWL1+uLKuHDx822XHkR+fvv/9WieAzYsSIEarYhRz3n3/+wS+//IJ33nkHly5dMlk/iGUysE4rjK3xGTRJ2sCDddfW4ctDX1p8ICXJOeI61aNHD9VM5UaVG/skhJiHCw8vYMahL/Tzn9QbD78itlMFluSR4J04cSI+/PBDnD59GgsWLMDixYuVpfeVV15RGRxMRefOnVX1towICAhQKYRGjx6tAuaEAQMGoEiRIlixYoX+0aT48AYHB6tUajL9uMpMktVBEh8bNmK5jGraFYPKvweNRvv9/3nhT/xw8gdzd4sQQoiZkEDmSVvHIU6TqOYHFaqNjvWG8fvIB5jUVCFiUcRtapcBydYgy/KytPDZs2fVa40aNYz6IZXgdOukepJkadAhvr7btm1DmTLpFy2YPn06pk2blut9J6bjvXb98XBDGDYFzVbz807OQ0HngnipxkscZhtFrkN//PGHmn7hhRfg5ORkkqI6cm0QpJKkuGoRQqwLecI3bc+HuBmlDVKvYeeKN3ssNHe3iDUKXnnUJ5kabt68qYSlIZKXN3Wp4dxEZ30tVKiQ0XJvb2/9Ok9PT2XVzSxioRYLtuExxDeYWDYzuw1DyD9hOBSmvbDNODwDns6e6F2pt7m7RnKBuLg49URJ6Nevn0kEb0xMjHqqZMpyxYSQvOXvy39jwy1tJqkCSRrM7LEAzo70yc8vmNSlQVwHBg4ciN69eyvfWn9/fxXA9ueff+Kll17C88/nXboPXTUkyc5giIhUd3f3bPsNS/1mw0asg196v4GqLs/p5z/aOwVbb241a5+I9SBPh+rWrasaSwsTYn1cCrmE6QdSqqdNrTUCpX1rm7VPxMp9eGfPnq3yVfbt21e5BpQvXx6vvvqqErtTpkxBXqFLP3b9+nWj5SLAK1WqlGf9IJaB3Iwt7TcFpew7qXkNkjBp51vYd3ufubtGrAC5gT5x4oRqLC1MiHUREReBiVvGIlaToOYHFKiCLo1fN3e3iLULXldXV8yfP1+V6pW8lceOHVNBYTNmzDDJo8XMIi4VVapUUdZlHXv27FGCt2fPnjnat6RcE9/gxo0bm6CnJK9wdLDH6oEz4J3UXM0naOIxbtvrOBKoza9KCCHE9vx2P9z9Lm5G3VXz1eCMt3r9bu5uETNgpzFhnqaoqCj4+voqH7fc5uLFi9i+fbvKriC+tW+88YYSuI0aNVJNkKpvTz/9NF588UVlbZ43b54KUlu40DRO6uIeIX7JoaGhdG+wIsLEH/OP4Yh0PKHm3Rzc8WuXX1C7KB9v2QISjKorY05/W0LyN4vOLMSso1+rac8kDZZ1/xOli9Uxd7eIGTSXSS284hsr/m15IXgfPnyoHi+KxXbkyJFKbMu8YYngTp064fjx4yhVqpQSxnPmzFGp0kj+pqCrK9b0/xEucdoMHtGJURixeSQuPrxo7q4RCy4t3K5dO9VkmhBi+RwOPIxvj36jn59eZxzFbj7GpBZeQdKPSZT0zJkzbb4iES281s3Nh4/wzMpXkOiizdRRyMUbi7stQgWvCubuGrEwCy+txoRYF0FRQej/v+fwIC5UzY/wrodxvZaYu1vEjJrLpGnJxPIhbgRSdOK3335DuXLljPx2xQK8a9cuWDviwytNcnMS66Vs4UL4o9d8PL/mVWhcbuJRbAiGbRqGxd0Wo7Qn081ZK3KjLdUdddOWuk9CSO4QnxSPSdsn6MVuczsPjO7Op7v5HZNaeCXh+6xZszJc7+zsbJTH1tqhhdc2OHgjAMP+Gwq43FHzJT1KKtFb3KO4ubtGCCEki8w4NANLzmutucWTgOXProF3ofIcx3yuuUzu0pCfoOC1HTZfuIoJu0bC3uWemi9bsCwWdV0EHzcfc3eNEEJIJtl4YyPe2vmWmnbUaLC4yUeoU6M/x88GsKigNfFz8/HxyfI6QsxNp2oVMbXxt0iKK6Lmb4bdxIjNIxAaq30kRqyHhIQErFixQjWZNgXivrR3717V6MpEiGVyJeQKpuz5QD8/uXg7il2SO4JXjMVSgjM9YmNj1XpCLJV+9WthfM2ZSIrXlsC+HHIZozaPUknLifUg15r+/furJtOmQK5rrVq1Ui2jaxwhxHyIceL1rWMQlaj9n+/pWAT9O3/Hr4SYNmhNrCi///67+nGR6UWLFhmtF4vI7t27VZ5cW4BBa7bLiBaNEBr9BRbdeBv2juE48+AMxmwdgx+f+hHuTtkrSU1so1KfrkKjTBNCLIeEpAS8vXMS/CO1cRjVE+3xYb9VsLM3eW0tYsWYxIdXcuCKmJVd3b17FyVLlkwTrFahQgVMnz7dpqqT0YfXdnl33X9Yc+9D2DtGqfnmJZpjTsc5cHFghL6lwxRihOQvZh2ZhUVntYY278QkLO38K0r6NTN3t4gtpiWTdGMBAQEqLVnHjh2xb98+U+yWELPxeY9OCF0VjV1hn8DOIRb77+7HpB2T8HW7r+HkkHclsgkhhGTMumvr9GJXgtRm1RlLsUvSxaT2fjc3NyV20wvqYKAHsSbksfWc53qhocvb0CQ5q2U7Anbgnd3vqByPhBBCzMvZB2cxde9H+vl3ijRF40avmbVPxHIxuYPLvHnzMGbMmDTL+/TpgzVr1pj6cITkGg72dvhlYF9UtxsPTZL2Ycjmm5vx7u53lc8YyT9IoFqPHj1UY9AaIeYnODoY47eOQ2yyAaKPXSEM6P6TubtFLBiT5+H18/PDoUOH0vjxHjt2DKNGjVLrbClo7dKlS9n2JyHWQUx8IvouWogbjnNhZ68Vut3Kd8P0VtPhYO9g7u6RVLC0MCG2TWxiLIZufBUng0+p+XoJdvh14FY4exQ1d9eIrfvw6pAMDYGBgShcuHCadZKD99q1a7AFxIItTTf4xLZxdXLA0sFD8OyCBNx1nQc7+0RsuL4BjnaO+KTlJxS9FoYEyS5cuFA/ban7JIRkHbHRfbj3Q73Y9U1IxDedfqHYJXkreB0dHVU2hlWrVuGFF14wWrds2TJUq1bNlIcjJM8o4OKI5YNfxTMLE/CwwC+ws0vE2mtrldid1mIa7O2Y/sZScHJywpAhQyx+n4SQrPPjyR+VwUFwS0rCnNpj4FOmBYeS5K3gFd577z0MGzZMuS60aNFCPfbftm0bfvvtN6xevdrUhyMkz/D2cMbKwcPQa2ECIrwWws4uCauvrIaDnQM+av4RRS8hhORyRgYRvIKdRoMvfNuhRpO0MUOE5IngFSuIg4MDvvjiC3z33Xewt7dHvXr1sHLlSnTv3t3UhyMkT/Et6IoVL41A70UJiC28RIneVZdXKdH7QbMPWJTAAhDXqk2bNqnpLl26qCdPOUVu3E+fPq2ma9eura5xhJC849i9Y/ho74f6+TedSqFDtzn8Coj5gtZS//BIeidb/XFg4Yn8y+V74ei75Ack+vwBOzvtv9Dz1Z7Hu03epeg1MwxaI8S28A/zxwv/DkJI7CM13yfBGVNe3Ak7lwLm7hqxIs2VK46HQUFBKsDjyy+/1ItdcXHIRW1NSJ5SuZgnfh84Cgh6HhqNttTsXxf+wozDM3ie2yBy4y6ZZ6SxtDAheUdobKgq764Tu03jEvH+c6sodon5Ba8I2+rVqyuXBiklrOObb75RwWy2gKQkq1Gjhk2VSSZZp7afF37tOwKJ9/rrRe/v539XZS55c2dbSDXJ27dvqybThJDcJyYhBmO3jsX1sOtqvnx8Ar7u+COcvMtx+In5Be/rr7+ODz/8UOXdNUTSeInotQXks5w7dw6HDx82d1eImWlaoQh+eGYE4gP76JctPrcYM4/MpOglhJBsIsV93tr1Fk7cP6HmCycmYm6d11GwQluOKTG/4JXAjlOnTilBmPqxX+XKlXHhwgVTHo4Qi6B9NV/M6j4cMXef0y/77dxv+PLwlxS9hBCSReQJ2acHPsUO/x1q3j0pCT+W6IrSTVg2mFiI4JWMDElJSYiOjk6zTiqSeXt7m/JwhFgMT9cpiU86DEXMnT5694Y/zv+Bzw5+hiRNkrm7R3KIlBPu16+faiwtTEju8sPJH1T2G8FRo8G37jVRo+ssDjuxHMErVl1JA/Txxx8bLb9+/bpydXj66adNeThCLIqBTcrg7ZaDEXO3r170Lru4DB/v/5ii18qRp1eSWlGaTBNCcoflF5dj3sl5+vnPUBTN+ywRgcEhJ5aVh3fOnDno0KED/vrrL2UJqVu3rvJ3rVOnDqZNm2bqwxFiUQxvUwHhMQPwwxF7uJZcrlKWiaUiUZOIqc2nsgxxHiClf7///nv9tKXukxBizJabW9RTMR1vxTih++C/AUcXDhWxzDy84tIglpDjx4+reclm0LdvX1We05ZgHl6SHvIv9fG6c1hy+n9wLblMFacQelboiU9afkLRSwghqdhzew/GbRungtWEVyITMPH5jYB3WY4VMYnmytXCE7aKpCWTJo82xTc5u4NPbBf5t3rvn9NYcf5fuJb6Sy96u5fvjs9afQZHe5M/XCGEEKvkcOBhvLblNcQmxqr5XpEx+KTXX7D3a2TurhELwuyCV6qpLV26NFPbioV3wIABsBVo4SWPIzFJg0krTmLtlU3Jolfr+9mlXBdMbz0dTva29cTDUpAb0d27d6vp1q1bm6TSowTjXr16VU1XrFhRBegSQnLOiaATGLF5BKITtMHunSKjMaPDbDhW68HhJZYleKOiolChQoVMbevh4aH/0bAFKHjJk0hITMK4v47jvxvb4FbqD9jZa0VvxzIdMaPNDDg70B/U1LC0MCHWwbkH5zB001BExEeo+bZR0fim6VQ4NXjR3F0jNqi5cvxcVaoOBQYG5nQ3hNgkjg72mD2wPkb9noSd/vZw8/sddvYJ2HprK17f9jq+af8N3BzdzN1NkgnkQksIMQ3nH5xXll2d2G0WHY1ZdcZR7JJcI9eey8kjQAphQgBnR3v88EIDNC/RCtEBg4EkrSvD3jt7MWrzKITHhXOYLBx5OvXo0SPVZJoQkn3OBp/F0P+GIjQ2VM03iInB7IqD4NJyPIeVWI/gjYiIwPDhw1GgQAFUqlRJv7x///425c5ASFZwdXLAT4MbokHRpoi6NRRIclXLjwUdU4/0QmJCOKCEEJvn1P1TGP7fcP2Nfv2YGMwt0RnuTzFtKbEywfv222+rEsKrVmmrpOjo1asXPv30U1MfjhCrwd3ZEQuGNEatInUReXM4kKi1FJ5/eB6vbHwFQVFB5u4iIYTkGseDjis3hvB4rdhtGB2DeYVboMDT37GwBLE+wbts2TKVtaFNmzZGy1u2bIn//vvP1IcjxKrwdHXC4leboEqhaoi8MQJ2iVq/0KuhV/HyhpcREB5g7i6SdIiNjcWQIUNUk2lCSNY4dPcQRm4eicj4SDXfNDoGPxRqDPfnfgHsc55JhZA8Fbzx8fEqiq5EiRKqzHDq9GWynpD8TiF3Z/w+rCnKe1VA+PURsE8oopYHRAQo0Xvt0TVzd5GkQq5fixcvVk2mCSGZZ/PNzRi1ZZQ+9ViLqGh8X7AB3PsuAhyYnpFYoeCVPLuSomznzp1pBO/PP/+Mhg0bmvJwhFgtPgVc8OfwZihTsDTCro+AQ0JxtTwoOghDNg7B2Qdnzd1Fq0WuQzNmzFDNVNUdc2OfhOQHll9cjjd3vIn4pHh96rHvCtSBa//fAEemZSR5h8krrYk7w5gxYzB27Fj14/D1119j/fr12LRpE3bs2KFcG6wdVlojpiIgJAr95+3H3YhgeFdYjHhHf7Xc3dEd37b/Fs1LNudgE0KsDpEW807Nww8nftAveyY8AlM9a8Nx0DLAiekYiZUVnkiPtWvX4osvvsDx48fVfOPGjTFt2jS0a9cOtgQLTxBTcCM4Ev3n70dQZCiKVFyCOEdtNhMpP/xpy0/RowIrDhFCrIeEpARMPzgdyy8t1y975VEY3ijSGHYDllDsEtsRvPkFCl5iKq4EhWPgTwcQHBkJ34orEO10Sr9uUqNJeLnmyxzsLJQWPnbsmJpu0KCByUoL3717V01LjAJLCxOSPmFxYXhr51vYd2efftmkByF4uVQ7oM8CujEQ6xW88nb5Mcjsj4oErtmKDxwFLzEll+6F4/mfDuBBZDRKVtyAcOc9+nVDag7BGw3fgL1drtWKsRlYWpgQ8+Af5o8x28bgeuh1Ne+o0eDj4AfoWb4H0Hse4JDj4q4kHxOWQ8Gb41/PqKgo1KxZE3/99Rfi4uIeW5Bi/vz5qFevXk4PSYhNUqWYJ/4Y3hTe7q64c7UHCsc9rV+36OwivLfnPcQnMtOJuXB0dFSNEJKWo/eOYtC/g/Rit1BiIn65G4SelfsAz86n2CVmxyQuDdu2bcOECRNw584dPPXUU+oxoq+vr/4x4KFDh7B161bUrl0bs2fPRpMmTWAL0MJLcoNzd8Iw6JcDeBQVj4oVTiPY9S8kaZLUuhYlW+Drdl/Dw4nlbfPSwksISR+REMsuLsOXh79UvrtChbh4fH/vPko3HQN0nArY88kUsQGXBh2ym82bN6ssDfv378ft27eVn1vp0qXRunVrvPDCCzaRocEQCl6SW5y5HYpBPx9AWEwCqlW4iWD3BYhN1BY8qFGkBuZ2nAsfNx9+AelAwUtI3hAVH4Vp+6fh3+v/6pdJjt2ZQQ/g2eVzoNlr/CqI7Qne/AgFL8lNTgU8wgu/HER4TAJqV3iABwXm6UtylvAoge87fo8q3lX4JaSCgpeQ3EcK5EzcMVFVidTxUmgYJoZGwfHZeUCtPvwaiG358BJCcoc6foWwZGhTeLo44vS1Iige9SaKuWsLVNyNvIvBGwZjd8BuDn8eIOWEJb+4NJYWJvkZsZGtvboWA9cP1ItdDw3w9b37eDsiEY4vrKTYJRYJLbw5gBZekhccvRmCwb8eRGRcIppVdoTGdyHOPdRWYpOsDe80fgeDqg/il5EMszQQkjuExobi0wOfYuONjfplleIT8U3gPZTz9AOeXwr4VufwE4vUXAw5JsTCaVjWG4tebYKXFxzCgcsJaG03Bh3K/Y1t/ltUMNv0Q9NxM+wm3m78Nhzsc55z1tqRtIdTpkzRT1vqPgmxJg7cPYD397yPoKgg/bJe4ZF4/8FDuJdtBfRbDHgUMWsfCXkctPDmAFp4SV5y8NoDDFl4GNHxiWhVuTDq1TmAxecW6Ne38WuDGW1mMIMDIcSkgWlzjs/B7+d/1y8rCAd8GHQPXSOjgEavAt1mAA68ESS5C314CcknNK1QBAtfaQx3ZwfsufwQx040xwdNp8LRTvugZlfALrz474sq+TshhOQUuab0/l9vI7HbLN4Of9+6ha7RcUD3mcDT31DsEqvApEFrkvNSilDMmjULQUEpjz0IIaahWYUiWPxqE3g4O2Df1QdYtbMkZrf7AQWdtf5MVx5dUcEke2/vzbdDLvm/z549q5pMmypQ59GjR6oxsQ2xde5H3cebO97EmK1jVICs4GLniHdCwjE/4CaKeZQAXtkINBlu7q4SYh7B6+bmhj59+uC7776Dn58fnn32Waxdu1bVtieEmIbG5Qrjt+TsDYeuP8R36zX4+anfUN6rvL6W/eito7HgzIJ8Kc6io6NRq1Yt1WTaFEhFSW9vb9VkmhBbRCo5/nb2Nzyz+hn8d/M//fJmDl7459YtvPgoBPaVOwOjdgOlG5u1r4SYVfA6ODjg448/xvXr17Fu3Tq4uLigX79+qvjE5MmTcenSJdgCc+fORY0aNdC4Mf/hifkC2ZYMa4qCro44cjME768IxPyOi9GudDu1XoLZvjn6Dd7e9bbywSOEkIyQG+Ott7Yq94Wvjnylz/ft7VgAn4cn4acrp1E6MQnoOAV4fhngXpiDSayOXA9aCwkJwZw5c5QQFktvq1atMHHiRGX9tXYYtEYsoSKbFKcIjY5HXT8vLHqlMf66/Ct+PPmjfhspTvFNu29QpmAZ5AdyIy2ZXCYTErRlUx0dHWFnZ5fjfRJiCZwNPotZR2fhcOBh/TI72OEZ11KYePEAvMUtqHAF4Nn5QOkmZu0ryd+EWWrhCfGd27RpE0aNGoXPP/8cVapUwYwZM1CvXj28/PLLmDRpUm4dmpB8Q61SXvhzeFN4uzvhZEAoXlpwCIOqDMO37b+Fu6O72uZSyCUMWDcAm29uNnd3rRYRuJKOTBrFLrEFzj44i7Fbxyqff0Ox28irCpZGOuGT8/u0YleyMIwUFwaKXWLdmNzCe+3aNSxcuBCLFy/GgwcPlEvDsGHDlGVXx/Hjx9GlSxerD2yjhZdYChcCw/DCzwfxIDIO1UsUxB/DmuJRfADGbx+PG2E39NsNqjYIbzZ6E84OzrBVWFqYkMcL3Xkn5mFHwA6j5WUKlMJEjTc6nPkX6vlFgWLAM3OByp04nMQmNJdJBa/80Hh6eqJ+/fpK5L7wwgvpdiomJgbPP/88/vnnH1gzFLzEkrh8LxzP/3wQwRGxqFbcE78Pawo3lwRM2zcNG25s0G9Xs0hNzGw7E35SGckGyQ3BGxcXh/fff19Nf/bZZ3B2tt0bBmJ7iE+/pBhbcm4JDgUeMlpXzL0YRhRpiGeProJT5H3twgaDgaem0VeXWBQWJXjFx+306dNK8OYHKHiJpXElKAKDfj6AoPBYVPItoCy9vp4uWHFpBb489CXikuLUdp7OnvikxSfoWLYjbA2WFiZEiwSsrr26VuXRNXzSoxO6w8t0xbPntsH5ZnIaQ5+qQM9vgbItOITE4rCo0sJiBRk4cCAuXryYZp2k8hEhnN46QohpEJG7bGRzJXpF/Padtw9/DG2G/lX7o7ZPbUzaOQm3wm8hPC4cE3ZMwLOVnsU7Td6xqeps4merixEwZWlhU++TkNxAbFhngs9g1eVV2HB9A6ISjLO0lC1YFoMr9ELvq0fhvOFTeQfg6Aq0mQS0GA848ukFsU1MauGVx4fFixdXr+lla6hYsSIePnwIW4EWXmKp+D+Mwou/HsTNB1EoVtAFvw9tisrFPBERF4Ep+6YY5dj0K+CH6a2no55vPbP2mRCSfYKigrDpxib8c+UfXA65nGZ9k+JNMLhyH7S+cRz2+78HdOkKa/cHOn4EFCrN4ScWjUW4NEi6sQ0bNijf3JdeegkrVqxIs37nzp3YtWsXjhw5AluBgpdYMkFhMUr0XroXgcIezvjt1SYqq4P8y6++shpfHPpCb/2xt7PH0FpD8Vq91+BkTwsmIdbAo5hH2Hxrs7LkHgk8Ao1Yaw2QTC3dK3RH/3I9UP3SNkCEbkyodmXppkCXzwG/RubpPCHWKHjFZ65IkSJqOjY2VhWcMEQCPCpUqIBvv/0W7dppE+PbAhS8xNIJiYzDywsP4VRAqKrMtuCVxqpSm+Af7o/397yP40HH9dvXKFIDn7b8FJW9K8NakZSIt27dUtNlypSBvX3Osy8yDy+xFG6G3cQO/x3YGbATx+4dQ6ImbSXTukXrok/lPuhSvBncj/+hFbrRIdqVRasB7d4Fajwj+fby/gMQYs2CN7/66VLwEmsgPCYeQxcfUWWIXZ3sMf+lRmhbpahal5iUqEoQ/3DiByRokgsr2Dsqa++IOiOsMn0Zg9aILRGbGIuTQSex5/YelUrseuj1dLcT39xu5buhW7luqCDhOQfmAccWA3HJLoY+VYC27wA1nwXsHfL2QxBia4I3v0HBS6yF6LhEvPbHUey4eB9ODnaY83x9dK1Vwqja0rt73jX6MS3vVR7TWkxDfV/ryrpCwUusmYSkBJUr99DdQzh496B6AqPLrpKa0p6l0bFMRyV0q3tXg92dY8CBH4CzqwGd5bdodaD1RKBWHwpdYtWYXfDGx8dj9uzZym3htddeU9MZIdu8/vrrsBUoeIk1EZeQhDeWncD603dhbwfM6FsXfRv6GVmSfjr1ExacXqC39goDqg7AhAYTUMBZm9s2v5YWlousIBdcVlsjpiI4Ohin75/GqeBTOHX/lMqwkDqzgg7xta9XtB7alm6Ldn7t1E2pnfjknl4BHF0E3DuTsnH5tkCL14FKHem6QGwCswve6OhoNG/eHO7u7ti6dauazgjZZt++fbAVKHiJtZGYpMHkVaew4miAmp/aswaGtCxvtI2UIp66bypOB5/WLyvqVhRvNHwDT1d42uLFHiutEUslMj5SZVAQUSviVkTu7Yjbj31PCY8SaFqiqcqy0KpUK3i7essdGBBwGDi6GDizCkiI1m4s6cXEZaHZaKBEnbz5UITkF8Gbn6HgJdZIUpIGn6w/h4V7tYnox7SviEmdqxoJWfHt/fPCn5hzfA6idT+mgLIuvdv0XRXcZqlQ8BJzIz+rImQvhlzEpYeX1OvFhxcREKG90XwcxT2Ko37R+lqRW6KJShuo/98MOg+cXgmcWQmEGBSSELeFhkOAOv1ZHY3YLGGWLnglJdm5c+dQunRpFCpUCLYEBS+xVuTf/vttVzBr8yU136+hH6Y/VxuODsYZDeRHWyq0bfffrl9mBzs8V/k5jKk3BkXdtcFv+aG08Oeff66m33vvPZYWJor4pHiV7UR833VNKppde3QNEfFp89GnxtXBFTV9aqJO0Tqo61MXtYvWhq+7r/FGD64C51YDp1cBQWdTlju5azMtNHwFKN2EbgvE5gmzNMErbg3r16/H119/rX5Uu3btiv/++0/9AG3evBnNmjWDrUDBS6ydpYdu4b1/TiNJA3Ss5ovvBzWAm3PaCO69t/eqvL2G5UndHN3wYvUX8UqtV1SpYkuBQWvElCRpknA/6r4StkrchiUL29AbCAgPMPJ3f5K4lXR/VbyroFrhaip1WCXvSmnzXicmAAGHgIsbgEsbgWDtTalCtq3cSRuAVrUb4Gw7FRIJsTrBK2nJlixZglq1amH37t3o378/duzYgeXLl2PPnj3YtGkTLInAwECsWbNGCfI+ffqkySH8OCh4iS3w39lAjPvrOGITktCgTCH8+nJjeHukTUcWnxiv3Bx+PPmj8kXUUcilEIbXHo4B1QbAxSHz/z+5heQCnzhxopqWG++s/E/n5T6J5SBuOyJeVYvQvoq4lenb4bczzJKQESU9SurFbdXCVVHVu6rKqOCQUTqwqIfA9Z3AxY3A5U0pOXMFe0egXCutyK3eE3DzzuGnJcQ6sSjBK+4L8vhQAtnE52jKlCmqY1Jw4t69e0oM37lzB5aClDmuW7cuOnbsqPolCeo3btyY6fdT8BJb4ciNhypXb2h0PCoW9cBvQ5uiVCG3dLd9GPMQP5/6GUsvLlUplAwD24bUHIK+VfrCXR63EmIhyHkqpXfvRt5Vbjp6QZsscCVTQlYRi205r3IoX7C89tWrvGplPMs8+fyPiwRu7geu7wCu7wLunhJHo5T1ImordwaqdNVmWXD1ysanJsS2CLMkwStIZ44fP64qq4nAnTZtGnr16oXz58/jueeeU6+WgqRQO336NH755RflfiFW6d9//131OzNQ8BJb4tK9cLy84BDuhsagWEEXLH61CaoVz/iiImJh7om5WH9tvVFJU28XbwyuORgDqw60mlRmxLqJSYhRYvZuxF3cibyDOxF31LzuVcRuehXJnoQ8sZCgMT9PP2WhlddyBbXiVoLLJE1Y5joYCgQcAfwPAtd3azMsJMUbbyMV0MRdoUo3bdlfB8cs95cQWyYsh4LX5P9RgwYNQvv27VG2bFncv38fnTp1Usv//fdf9OzZ06THEmE9f/58XLhwQVmR69Wrl2abdevWKReL8PBwtGzZEm+88YZKjyZIRbjGjRurabFIy7TsK7OClxBbokoxT6x6rYUSvZeDItBv3n78PLgRmlXQlg1Pjfz4T289XVl1552chy23tqjlIbEhmH1sNn49/St6V+qNQdUHKbGQV8jNa3Cw1mLn4+Nj8WnUyJMJiwvTitmIO0rQ6oSt7lWeOmQXHzcfJWp1gla15HlZl+XzR2xIj24C/oeAWwe0IveeBJulsi15lQEqtAHKtwPKtwE8i2X7MxBCzCB458yZo4SnuAgsWLAAbm7ax6IifidPnmyy40ydOhWrV69WVmMRvY8ePUqzzeLFizF8+HB8+umnSoB/8skn2LZtG7Zs2aIuYvLDaHgx0y0jJL9SspAbVoxqrtwbjt4MwUu/HsSXferguQYpBSpSIz6K37T/RuUX/fn0z9h0Y5MK9JEo9d/P/44/zv+BdqXbqQC3xsUb57oAlRLnvr6+Js3SIIFwuiwzcq0xxT6JFrnmPoh5YCxmdRba5PnMZDzICC8XL+VTK/lsSxbQvurEbakCpXLufhP5ALh7HLgj7QRw+ygQfjftdoXKAmWaAWWaAxXaAt7lmVmBkDzEavPwPnjwAEWKFEFAQIBKebZ9+3a0a9dOvz4pKQl+fn4YOnSoEro6i261atWwYcMGlT1Cgk9kmQhmoXbt2li4cCEaNWqUqT7QpYHYKjHxiXhz+UlVlU14vWNlvPFU5UyJVYleX3R2EdZdW6eqtxlStmBZZfXtWaEninnkjkWLWRosz3/2XtS9NG4GulcRtFkNCjNMkSe+4yUKlNCK2nRePZxMeHMiwWV3TySLW2kngdBbabeTQLMSdYHSInCbal0UPIubrh+E5EPCLM2HV5BsDAcOHEhjdZXI5g8//NCkx8pI8J48eVJZmg8dOqR3WxCqV6+Obt26KbEbFBSkgtaeeeYZZZGWwZSMEo+L1JamQ7aXY2d38Amx9AIVX/13ET/uuKrme9criS/71oGLYwaR5qkIiQnByksrsfTCUgRFBxmtE9/HliVbomfFnmjr19akQW65IXjlBvruXa34L1GihApwJSkZDgz9Z1O/iv+sWPyzg6O9I4q7F9dbZg1fRdCKH62TQ6q0XqZAfhZDA7SuCFKuN/C0VuCKq0J6FKkElKgHlKyf0pwZuEmITfvwiquBWFTr1KmjOmaIzr0hL7h5U3thKlWqlNFymdetk8eeIoj//vtvJYqff/75x+5z+vTpKgiPkPyAvb0d3ulaDWULu+P91Wew+sQd3HkUg/kvNUw3bVlqpATq8DrDMaTWEGy5uQWrLq/CwbsH1ToRQLtv71ZNAoPa+LVB57Kd1aslZngQgZv6WpIfEHuI8p9NxzKre82J/6zkck7PMqsTtlLYJNOBYdklNkJbwUyErRK4yS02NP3tC1cwELf1tJZcZlEgxOIxuYVX/NxWrVqlUn3lBRlZeFeuXIl+/fqp1GPe3il5CyVjhHzktWvXZvlYtPCS/Mruy/cx+vdjCI9NQHkfDywc0hjlfLJuOZXMDv+7+j+svrIagZGBadY72zujYbGGaFmqpbIAVyxUMcs+vywtnHnE5eRe5D31XQRGBWpfk5uy2kbeNcq5nFUkY0eG7gYeJZR/bZ4FFSYlasvxpha2IdfT317cEnyqAsVqapsIXBG3brZVMZQQa8GiLLyShzc+Pl5laTA3OpErvr6GglfmK1asmK19iksGE86T/EjrykWxanQLvLLwMK4HR6L3D3vxw6AGaFHJJ0v7kUAhKUk8qs4oHLl3BP/d+E9ld9BZCcWXc//d/arNxEwUcy+mxG+jYo1UZSoJNjJH1gUpLSxpDIXx48dbRWlhKXsrFcL0IjaVoBW/2pxYZ8XyKv6zj3M3MJu1Xnxtg86luCTIq1hx46PS375A8RRhW6yW9tWnCuBo+d8zIcRMFt7WrVtjxowZaN68Ocxp4ZW0RMWKFcNff/2lqr3pLLTixiBuF5KeLLvMnTtXNRH4ly5dog8vyTcEhcdg+G9HcdL/ERzs7fBhj+p4uUW5HIlQCWo6eu+oEr/i4iBWxYwo7FoYdYrWUeK3tk9tVc1KluWnoDW5ZIfHhyM4Khj3o++rJtPiJ62EbLLFNjgmONu+s4KUvBUBm5G7gQQdpimLm9ckxgMPrhgLW2lht9Pf3tEV8K2eImx9a2inPbJ240YIyecW3oSEBJXrVoLAxo4di/Llyxv9EDo5OWHAgAHICyT/5tNPP41Zs2ap/L/iP/zdd98pC3RO+zBmzBjVdINPSH7B19MVy0Y0w7t/n8Y/x29j6tpzOH83HB/3rpnpYLb0ApOalmiqmoi566HXsffOXuy9vVdZgQ0zPYhFcof/DtV0FHEtgkrelVC5UGXlAlHcuTj6Duqr/EMdHU1ziZP9vPzyy/rp3Ar+ehTzCI9iH6k0XWKdlQpgStDKa1SyuI0OTpP9Iqs42Dko/1gJCBNLrL4lz4uYlRuJXPefzQoRQckBZAbCNvgikJhBhodCZVKstTqBK/63GZX3JYTYNCa18Er+S6mwlhFiFbl6VRvxnVOkBPAXX3yhrLaSEUKyLYj/8JAhQ1QTAgMDldi9fv06ihYtqqKsJTew5O41BUxLRvIrctn4Zfd1TN9wHkkaoGFZb/z4YgMliE1dQevE/RM4GXRSvZ66f0oFUWUGEWviEiEWSRFx4k8qIk6aBNTJq6SsEmEsTR6/S7nYrFqrZSwSNAmIT4xHXGIcIhMiEREXgaiEKOX/Kjlko+Kj1DKxzOpErRToCI0NVdks5DUmMQamQgompBazImJ1y2S93GhYJAlxQPClZIutgcCNNM70ocfZEyiWbKnVW26rM5CMEBsjzBLTkuUFImalKlpqypUrp5oh586dU5XWJM+ursqaKaDgJfmdnZfuY+yfxxAek4DiBV3x0+CGqOOXe0E98oj+RtgNJYAvhlzElZAruPzoco58UVPndXV1dNULXxHNskw3LX/igiH+seJvLCJXpg1LK+cmEuQlfrMiWNWru4/RvBK27sVyJ1VXblltJeWXoUvC/Ytpy+4q7IAiFZNFbe0UkSsVy5gmjhCbJ8xSBW9MTAxu376d7QAxa4CClxDg2v0IDP/tCK7ej4SLoz0+f7Y2+jTMuDJbbvAg+gGuPrqKq6FXcTv8Nm4G31Q5YO/F30NoXAbppSwAsbKK5bmQayEUcklpYoEWAZta1Do7WGkQlbLaXtQKWkOBG3k//e1dvLRitrjOJaE24FsNcGaFO0LyK2GWJnilI6NGjcLy5cuV36wEeAji1/vll1+qSmfWDoPWCDEmPCYeE5aewNYL2sfOzzcpgyk9a8DVKe/9JVMHmMEZyv9VrMDiPvAw9iEeRj9UbgXidiDuBuI/q2syLz6yYrWVy2MSkpAQnYA9o/aoZa3ntYaru6sSnxK0pXsVq6q8iptEAacC6tWwybICzgWMRK27o7tZsk7kKjFhWjF79yRw9xQQeAq4fwFISniM1VaEba0UgetVmmV3CSGWLXillK+/v7+qqCYVzXSCd+nSpVi3bh1+//132Aq08BKSQmKSBnO2XcbsrZdVoaqaJQvixxcaokyRvE1NZetZGiyKiPtAoIGwFZH78FrGVlu9xbZWiq8tK5IRQqxR8ErOW/GtlR8HSQumE7xS3axZs2b68py2AAUvIWnZdek+Jiw7gYeRcfB0dcTX/euhU41ieTZUuVVaWBdwK25a+a60sCq1668VtiJqlbg9BYTfSX/7gn5AiTpA8TraYg0idGm1JYTYSloySfklPzCSEkyyJ6T26bXS+DhCSBZoU6Uo1o1rpYLZjt16pPx7R7atgLc6V4Wjg3UKRRG4lStXRr5ArtNipb1zHLh7IsV6Gx2SsUuCErV1kkVuXcCjiBk6TggheSR4Jc9ulSpVsHnzZrRt29Zo3ffff4/GjRvbxHdh6MNLCElLyUJuWDqiOb7YcAEL9l7H/J3XcPzmI8x+vh5KeLlxyCzKchsA3DmmFbi6FpNOoJ+kMRMXBBG0ImxF5Ip7gounOXpOCCHmdWlYvXq1yoMrvrwiCj/55BOsX78e+/fvx549e2xG9Ap0aSDkyWw4fRdvrTyFiNgEeLk54YvnaqNb7RJW5dIgT69++uknNT1ixAh1c2+VhAemiNrbySI3Kjjtdg4uWjeEkvVTrLcidh1dzNFrQgiBxfnwClu3blXlhY8fP67mReROmTIFTZo0samvjIKXkMxxPTgS45cex6kAreVwYOPS+KhnDbg7m774AYPWdAPxwNhqKy09n1tlua2hFbelGmhfi4q4tdIUaIQQmyTMknx4dXTs2FE1QggRyvt4YOWoFvhmyyXM23kVSw/749D1h5g9sD5q+5m2PLeDgwP69u2rn7bUfZqUhFhtftuAI8DtI9rXkOtpt5NSwT5VU4StNMmW4GTaCnmEEGJpmNzCKxXQNm3ahBs3bqgfBolo7tKlCwoXLgxbgxZeQrLOvqvBmLjsJALDYuDkYIc3OlXBiNYVrDagLc+RS7aI2YCjQMBhrcAVsZsYl3bbIpVShG3JBkDx2oCL1t2DEEKsCYtyafjuu+/w9ttvqwwN4kMnqXyioqLg6emp/N8GDhwIW4KCl5DsERIZh3f/Po2NZwPVfN3ShTCrXx1U8mUAVBqiHwG3j6ZYb2U66kHa7dwKA36NAb9GQKmGWiuumzdPUUKITWAxgnf79u3o3LmzKjgxcuRIlYNXl3/322+/xQ8//KB8emvUqAFrh5XWCMk5culZeTQAH687h/CYBDg72uPNTlUwrHUFONjbWPWxzJIYry27qyy3ySL3weW020mJYQkkU+K2EeDXEPAuz+pkhBCbJcxSBO+zzz6L2rVr4+OPP053vWRtkFLDkp7MVqCFl5Ccczc0GpNXncbOS/fVfP0yhfBV37qo5FvAYoLW5EmVLg/v5cuX4e7ubrrAsoBDgP9BwD9Z5CZEp91OxKxe3DbSuiYwYwIhJB8RZimCt3jx4jh48CDKli2b7npJSzZ69Gh95gZbgIKXENMgl6EVRwLwiVh7Y7XW3nHtK2FE2wpwcXSwjSwNSUnA/QtacSsWXHl9cCXtdq5eyS4JyeJWpj18cvwZCCHEmrGILA1SgOHhw4cZil1BrCN37mRQhpIQkq+xs7ND/8al0aqyDyb/fVqVJ561+RJWn7iNz56tjWYVzFu5y9XVVX+zLtOZIiZMa7H1T7bgintCbDoFHSRrQunGQOmm2lakspR2M/EnIISQ/I1JLLxi/RCfXbF8ZISsK1GiBMLDw2Er0MJLiOmRS9Kak3eUtTc4Qpt5oG9DP7zXvToKezibxcKb6cwJOnErr+KLi1SXVycPbTCZTtyKBdfd9jLYEEKITVp45QdKrLwrV67McJuYmBi1HSGEPMna+0y9UmhXxRdfbrqAPw/eUsFtW8/fwztdq6Ffo9LmD2pLTADunQFu7U9uB4CIe2m3K1QmRdyWbgL41gQcciX9OSGEkNy28IoVRVKPPQmxtDzOCmwtMEsDIXnH0ZsheP+f07gQqH06VKNEQVWlLSM3h1wpLRwVhj/mfqF8bl+oGgunu0eBuPC0mRNK1NMKW2l+TYCCuVdCmRBC8hNhlhK0lhkhK5abPHm8mEfQpYGQvCE+MQmL993A7K2XVQozoWvN4srNoUwRd9ML3qiHWqttsgU38sZxFPjsoXaf73rCw9kOcCmotdyWaQaUbaEt7MCKZYQQYrsuDYLuB4YQQkyNk4O9ys/7XAM/fLP5Ev44eFMVrdh2IQivtCqH0e0qwcvNSW0rFR67d++un84Uj25pBe7NfVqRK9kUDHCABt2reahCDg5dJwNV2gC+NQB7CywzTAghJPdLC+cnaOElxDxcDAxXQW17rgSr+YKujhjVriJeaVEebs4OmUgPdl4rbG8m+9+GBaTdzqeK1npbpoX21bscCzsQQkh+d2nIj1DwEmI+5NIlFt4vN17ApXtal6qini54vWNlDGhUWuXyVSTEAneOpwhc/wNATKr0YHYOQIm6WteEMs21Ape5bwkhxGKg4LXiwSeE5JzEJA3+d+I2vt58CQEh0XBHDLp43sSrpe+gZsJZ2N85BiTEGL/JyR3wa5wscJtpp51tJ76AEEJsjTBL8eElhBBz4BAXjuc8z+OZ+rvx8Ow2ON8/i5LTHuEnAEGTtAFmGncf2Cn3hOZA2eZA8TqAg9bnN7OlhevWraumT548abrSwoQQQvIECl5CiHUR/UjrnnBjD3BzL3D3JKBJgnjuFpUsDdAgKl676Qfxr+CkphYe2ZfFq77l8ULdsvDORPGK9NwnrlzRlgGmFxghhFgfFLw5zMNLCMllJEWYCNsbe4Gbe4DAM2krmElAWdlWQLmWgE99YHoNtbhez9dw7OBdPHwQhZn/XcL326/g2fp+eLVlOVQu9uTc4TqknPCePXv004QQQqwLBq3lAPrwEpILRNzXClydyA2SEr2pKFxRK251ItfLL8M8vC6ublh36i5+3n0NZ++E6bdrXdkHr7Yqj7aVi8Le3JXbCCGEPBYGrZkRCl5CTEB4YIp7ggjc4Itpt/Gpmixwk9tjKphlVHhCXBEOXX+IhXtv4L9zgUhKNhKXLeKOgY3LoG9DP5XlgRBCiOVBwWvFg09IvkQE7vXdwA1pe4CHV9Nu41vTWOAWEO/czJGZSmv+D6NU5bZlh/0RHqut3OZob4fONYthQOMyaFmxCBwdktOaSWazhAT8888/avrZZ5+FoyO9wQghJC+h4DUjFLyEZILIYK241Ync4EupNrADitdKcU+QQg8eRbI9tFkpLRwVl6DcHf46dAvHbz3SL/cp4IJedUuid/2SqF3KS2VpyHG5YkIIIdmGackIIZYZZKYTuEHn0hG4tYHybYByrbR5cN28TXZ4e3t7tG3bVj/9ONydHdG/UWnVzt8Nw9JDt7Dm5B0ER8Riwd7rqlXw8UC36kXQtEUruDo5PHGfhBBCLA8GreUAWngJgbZqmVQwU1bcXUDg6bRZFMRFoXxroFxrbbEH98IWO3RxCUnYffk+Vp+4g83nAhETn6RfV6tUQXSqXly5PlQr7gk7Owa7EUJIXkCXBjNCwUvyJbER2vK8Im7Finv3hMqDa4RPFa241YlcKy3TGxGbgE1nArH6xG3svRKsD3QT/Lzd0LlGcXSqUQyNy3kb+fwSQggxLRS8ZoSCl+QL4qMB/4MpLgq3jwJJ2kAvPd7ltS4KOjcFz+KwNcTNYdv5IJXhYfflYMQmpIh8LzcntKxUBC0r+aB1paIoU4SV2AghxJTQh5cQYloSYoGAIykuCgGHgcQ44228yqRYb+XVIA+uuZGgtXLlyqnpGzdumCTALDo6Gk+1aq6m9+/fD42DE3ZdCsbmc/ew9cI9PIqKx7+nA1UTyhR214rfyj5oXqFItqq7EUIIMR304c1hpbVLly4xLRmxbhLjgdvHgBvJLgr+h4CEaONtPEsaC1ypbGahZCVLgyn2mZCYhJMBj7Dn8gPsuXJfZXtIMPB9EDff6sULolE5bzQsq22lCrnR/5cQQrIAXRrMCF0aiFWSmAAEnkxxUZCAs/hI4208ihr44LYBilTUKjcrIDcEr9zcbtu2TU136NABDg4Oj/X7PXhNxG+w8vu9dC8izTbFC7oq4dugrDcalfVGtRKecHHMeJ+EEJLfCcth7QNaeM04+ITkCUlJ2vK8KshsF3BzHxCbUmJXIWnBlMAVH9zWQNGqViNw80Lw5oR7YTE4ciMER24+xLGbIaq8saEFWHBysEPV4p6oVdILNUt5qdy/kgVC0qARQggBBa85oeAlFkvIDeDaTuDaDq3IjQo2Xu/ipS3yoLPiStowG8kva2mCNzXRcYnKBeLozRDVjt0KUT7AqXGwt0Nl3wKoWdILVYsXQOVinqhazBMlvFzpDkEIyXeE5dDIyPqYhNgCkQ+A6zqBu1MreA1x8tDmv1WZFFoDxesA9rQeZhYpLbxp0yY13aVLlxyVFnZzdkCzCkVUEzQaDQJConH2TihO3w7FmdthOHM7FA8i43AhMFw1QzxdHFGpWAFU8fVEleKeqFKsACr7eqJYQRcKYUIIyQC6NOQAWniJ2YiL1PreXt+hFbmq2IMB9o5AqUZAhXbaVqoh4Jg/MgXkddBabiAi+F5YrBK+4gJxKSgcl++F49r9yDTuEDrcnR1QtoiHqgxXzscd5X0KoLyPO8oV8UBhD2eKYUKIVUMLLyH5JdDszjGtuJUmmRSSUj0GF7cEJXDbaq25Lp7Ij0jp30aNGumnLXWfj0MquBX3clXtqRrFjKrA3XgQiUv3wlUwnIjgi/fCcfNBFKLiElV5ZGmpKejqiPI+HkoQly7sBj9vd5T2dlfFM0oWcoOzo224sxBCSEbQwpsDaOEluYZGA9y/kCxwdwI39gBxxo+24VU6xYIrrgoFfPmF5FPiE5Pg/zBKiWGxAsvr9eBI3AiOwp3QaHU6ZYTEJkrWCJ0A9iusfdXNi88wq8gRQswNszRY8eATYsQj/2Q/3J3a14h7aTMpiLBVArctULiC1WZSIHlHTHyisgBfD47ArYdR8H8YjYCQKPiHaF9j4lOVhU4neE5Eb0kvN5Qo5IoSXmIV1r6q5YXc4O3uRJcJQkiuQpcGQqyVqIfaPLi6bAoPrxqvd3QDyjZPEbgq0IyPnknWkNRmkvJMWnq+whIcJ9ZhCZzzD0l+fRiF20oQRyMuMUm9Ssv4GPZ6AWwkiAtphbLMe7o68asjhJgNZmkgJK+IjwZuHUjJpHDnhEiOlPV29trgMhG3InJLNwEcXfj9ZJGoqCjUqFFDTZ87dw7u7u6mKS381FNqesuWLXBzc7OJ70V8hX0KuKhWv4x3mvVJSRrcj4hVluA7j2JwNzRa/3o3NEZNB0fEKiuxuFBIywjJLpGRhVgnlCWDBSGE5AYUvITkFkmJWlF7bbtW4N46CCTGGm9TtFqKwJW8uK5e/D5yiFgtb968qZ82BUlJSdi3b59+Or9gb2+HYgVdVWtYNv1tYhMSEZgsflOEsPFraHQ8wmMTEH4vIt3KczrENSI9C7FOGEs/GGBHCMkOFLyEmAoRV8GXU/LhirtCTKjxNp4lUzIpiNAtWILjbwW4uLjgn3/+0U8Tg7Fx1KZDk5YRkbEJSvgqQfwoRgXS6V9l+aNoRMYlIiQqXrVz6WSaEMRlXazRJb1SCWIDy7Gvp6vyOyaEEEOYpSEHMGiNIOxuisAVX9zwO2krmkmhB102hSKVGGiWzyutkbSIJT4sOiFZABu4TRiK4tAYlZbtSYjYLebpghKF0rpM6CzHPgWYl5gQa4NBa2Zg7ty5qiUmJprj8MSciMVWUoTpAs2CLxqvd3AByjRNDjRrB5Ssx4pmhGTCl9jL3Um16iXSz3ijC7BLsRAnu00kW4hlOjAsBolJGrVMWkY4O9irHMdGgriQm95yLMLYy42ZJwixJWjhzQG08OYDEmIB/4MpAleKP2gMrUx2WlGry6RQphngZBsBTdZKblh45eZ29+7darp169ZwcGBwlSUiYvd+eKzeZcLQWqwTxhKElxnXbqlcJ6K4VCEp1KEt1qF9dUOpQu7w9XRRPs6EkLyBFl5CTB1oFngqReBKVoWEVOmYxC1BJ3DLtQLcC/M7sHFiYmLQvn17NU03CctF3Bl0FepQJv1txC3iXpjWRcJIEIvlWFmKo5UfsVSukyIe0jKyEoslWCeElTBOrmIn8/QlJsSyYNAayd+IqefhtZRUYdd3AdEhxtsUKJaSSUGCzbz8zNVbksnH47q0ZDJtqfsk5kGyPJQu7K5aRkTHJeozTtx+FK3PSSzp2eRVXCckP/GNB1LdLirdfTja2yl3CZ1VWISwiGI5bpnCtBATktfQpSEH0KXBSokISq5mlhxoFupvvN7ZU2u51QlcSR1GkUMISSYhMUmJXl1BjtsGYjjgUZRyp0hI0jxZeHu7qewWIoB1Qlg77QZ3Z9qjCDGELg2EPInYcODmvuRMCjuAoHPG6+2dgNISaJZsxS3ZAHDgjw0hJH0cHeyTXRfcM/QlFrcJnVVYZyHWVbITq7G4Vly9H6laekj6tTKF3QxEsLteHNN/mJCsQwtvDqCF10JJiANuH0lJFSbTSQnG20iZXp3ALdMccGbqKkJIHl2iEpOUu8Sth1G4+SBKvUo551vJTQp1ZMY6nFoMl/fRinApJ02IrREWFgYvLy+EhoaiYMH0s7k8DgpeMw4+MRFS+SrobEqgmVhz41NZTbzLpeTCLdcG8CjC4bfh0sKNGzdW04cPHzZZaeFevXqp6TVr1thMaWFimYRGxStrsE4A6wSxiGOxDosFOSPE+0qKcZTzcUc5JYI99GJYhLEUCiHEGqHgteLBJzkg5EaKwJVAs6hg4/XuPinVzORVBC/JF+RGWjIWsyCWaB3WtwdRuPkwEjeCoxARm+ppVjpiWCuC3dWriGIRxxTDxNKhDy/JH0Q+SKloJq8ieA1x8gDKtkix4vrWAOztzdVbYmNIOeHff/9dP02IOf2HdVkmWmZQnONGcCSuB0cqi/D1ByKEtU3KN6usE4+iseeK8XslpbBkldAJYPWqpj1UEB0tw8TaoUtDDqCFNxeJiwRu7geubdcK3MDTxuvtHYFSjVIyKci0o3Nu9ohYCbTGEpIWEcPBEXG4oRPA8ipp1QzEcEboxHCFogVQwccDFYt6aKeLeqB4QVem6iN5Ai28xDZIjAduH0ux4vofApJSBW741kwRuGLNdfE0V28JIcSqkPzRRT1dVGtcrnCGYlhrGda6R+imRQzrUrDtunTf6L0ezg4oLwLYRyuAdaJYpplajVgSzL1EzFfw4f6FlFRhN/YCceHG23iVTs6k0B4o3wYo4Mtvi5gFKS187NgxNd2gQQOWFib5SgxLOebrUnUuWCrPRWgr0AVHKv9hEcNnboeplpoSXq5aEexTwMgqLH7ELMtM8hq6NOQAujRkkUf+yRZcKfqwE4i4Z7zezVsrbHVlewtXYMEHkmUYtEZI3iC5hEX0igiWfMJKDCeLYinPnBGuTvbKP7hisgDWiWJ59XR14tdH0oUuDcRyiXoI3Nidkk3h4VXj9Y5uQNnmKQJXcuMy0IyYwFpVtmxZ/bQpyI19EmLtSD7gSr4FVEtNSGQcrgXrhHCKGBYXiZj4JFwIDFctNcUKuqCyr6d+v7pWxMOZ/3skR9DCmwNo4U1FfDRw60BKJoU7J+SBmMHZZg+UapicKqwdULoJ4MiId0IIyU9p1cQXWInhoEgjURwcEZvh+7zdnZLFb4oYruxbQLlN8CY0fxDGwhPWO/hWT1KiVtTqMincOggkprpgFa2WInDLtQRcvczVW0IIIRaMVJi7ej8CV+5F4Mr9CFy+F65eRSBL2Ed6SNCciN+KehGsFcRSgc5B0ksQmyGMgjf7iDP+vXtaP1JXV1cUKlQoTwff6pArTvDlFAvu9d1AbKjxNp4lUzIpiNAtWMJcvSWEEGIDRMclKiEs7bKI4SCtIJZ0agkZVJ0TdwuVQi3ZEqwTw5JjmDmFrRMK3hwOXpUqVRAbG4uOHTti5cqVeTr4VkHY3ZRUYeKLG37HeL2LF1C+dUrBhyKVGGhGzIqUAW7Tpo2a3rVrl0nKAMfExGDgwIFqeunSpeoGmRBiXuITk5RPsAhgJYSTBbG4SYifcHqI1Vesv4ZuEVWKaa3Crk4su2zJUPCagHXr1mHRokUUvEJMKHBjT4rADb5oPFgOLkCZpsmBZu2AkvUAe14kiOXALA2E5G+SkjSqmtzloHCtNVgEcfJreEz6pZfF+0GEsIhf1YrLq+QULqCsxcT82HyWhiNHjuDChQvo1KkTihUrlq7lZffu3QgPD0eTJk3g5+enXxcfH48HDx6ku9/ixYvnar+thoRYwP9gSiaFO8cAjeGdsZTYqZeSSaFMM8Ap5xYzQqwJZ2dn/PTTT/ppQojlIjl+deWXO1QrZuTGGBQeayCCw5VF+NK9cJVGTVWeexCF/86lpMx0tLdT5ZWrFvNE5WIFkl89Ua6IuyrzTKwHixW8//33H959911ERUUpwbt9+/Y0gvfKlStKCMsjy1KlSuHFF1/E119/jVGjRqn1x48fR69evdLd/+3bt/Nn8ngJNAs8lSJwJatCQrTxNuKWoA80awW4GyciJyS/4eTkhOHDh5u7G4SQHCDZHIoVdFWtZSWfNJXmRPhqm1YESxOLsE4gw6DCvbODvcobLNbgqsU9lWuEvJb2dmdRDQvFYgWv+NXOmzcPJUqUQOnSpdPdZsSIEahcuTI2bNigxOvChQvVss6dO6NChQrK4hsYGIh8jQSaPbxmEGi2C4gOMd6mQLEUgSvBZl4pVnJCCCEkv1SaSy2EA8NitAI4UCeGw5V7RFRcYkou4ZPGRTUkOE6swUoMJ1uGSxVyY/o0M2Oxgrdnz57qNSAgIN31d+7cUVbfNWvW6C21L730Et566y0sX74ckydPztRxgoKC8OjRIyWwRRwXLlw4w0eWso00Q38SiyQiKLmaWbIfbqi/8XpnT63lVpXtbadNHcZk+oRkSFJSEs6fP6+mq1evDnsWSCEkXwjhEl5uqrWtUjSNj3Bqa7AIYQmWO307VDVDCrg4qsC4qsn+wdWSW5ECzEWP/C54n8SZM2fUa61atfTLHB0d1Y/R6dMGzx2eQPv27fV+vvXq1cNff/2llqXH9OnTMW3aNFgcseHAjb0p2RSCzhmvt3cCSjdNEbgl6wMOLN9ISFYyP+iuNREREfDw8ODgEZJPMfQR7lg9xdUyMUmjSi1fDBTf4HBcFBGcnDUiIjYBJ/wfqWaITwEXJXyrJrfqxQsqizAzRpgeqxW8EqUneHt7Gy0XC61uXWY4e/ZsprcVn+KJEycaWXgzcrfIVRLigNtHkjMp7ABuHwWSUkWeFq+dkklByvc68wea5B98fHysYp+EENtBUp6V9/FQrWut4kbp0yRnsFiDRQRfDAxTovjmwyhVXW7PFWnBRhkjyhXx0IvgasULKlEsWSREbJN8Jnh1uTXF2mJYMEKyNRQpUiRXjuni4qJanpOUBASdTUkVdnMfEB9pvI13uZRMCuXbAB78cSb5E7G+3r9/3+L3SQjJHzg52KvMDtJ6IKUYU1RcglYEB4YpX2ARwfL6MDIO14Kl7HIkNpxJiUNyc3JQqdIMRbBM0y3CxgWvBKUJN2/eNEpFJvONGzeGTZEQA/zcAUiMS1nm7pNSzUxeRfASQgghxCpwd3ZEvdKFVDMMlLsfEavEr04AXwgMU64R0fGJOBkQqlp6bhE6ASximG4RNiR4a9SooUSvBKi1bNlSLTt8+DCuXbuGHj165Oqx586dq1piYiLyBGd3oGIHbUoxXUUz3xriSJQ3xyeEEEJIngTK+Xq6qta6clEj/+AbDyK1IvhuskX4XrjyGc7QLcLHQyuCixVMFsL52y3CTiO3ExaICNd9+/YhJCQEr7/+Ot577z0VkFanTh3VhLVr1+K5557D6NGjUaZMGcyePRvNmzfHsmXL8qSPeVpaWL4mZlIgJFMBZt26dVPTkrLQVKWFhw4dqqZ//fVXlhYmhFgEOrcIvQhOtghLIY30cBO3CBUcpxXA1UuIa0RBeLlbfiC7zZYWlupp8+fPT7NcBK40HUePHsUff/yhfHfF0iupyfKqoESeCl5CSKZgaWFCSH5GuUWExxr5BV+8F6aEcVyCYSXVFCRPsF4Al9C6RUjwnQTiWQo2K3itAQpeQvKH4JUy5eLGJIwZM0ZVXiOEEGsiQbJFPIhSFmARwufvhuH83XCVUzg9XBzt9anSRASLGK5uRmswBa8ZMPThvXTpEi28hNi44CWEEFslNDpe7wqhE8EyL0Fy6VHSyxXVlCuEVgRL0J3kJM5tKHjNCC28hFgeFLyEEJIzEpOLaIhvsBLByRbhgJC01uDBzcvi42dSioBZquay2iwNhBCSl6WFb926paYlQJalhQkh+aWIRrfaKbmDw2LicUnnDpH8aphWzZKh4CWEkExkfihfvryappsEISS/UtDVCY3KFVbN2qDgJYTYHO7u7laxT0IIIXkDBa81FJ4ghGQaCVITP15L3ychhJC8g2nJcgCD1gghhBBCLF9zsTYtIYQQQgixaSh4CSE2hZQB7tGjh2oybQpiY2MxfPhw1WSaEEKIdUGXhhxAlwZCLA+WFiaEENsjjHl48x4GrRGSv5BSwp9++ql+mhBCiHVBC28OoIWXEMuDldYIIcT2CGPQGiGEEEIIIRnDPLyEEPIENBoNgoOD1bSPjw/s7Ow4ZoQQYkVQ8BJCyBOIioqCr6+vmmZpYUIIsT4oeHNo9dH5lRBCLAPDimjyv2mKioi5sU9CCCGZR6e1dNorqzBoLQcEBASgdOnSOdkFIYQQQgjJJP7+/vDz80NWoeDNAUlJSbhz5w48PT1z3adP7mxEXMsXnZ2SevkFjhPHiOcR/98sCV6TOEY8l0yDWHbDw8NRsmRJ2NtnvW4aXRpygAx4du4ycoKIXQpejhPPJf6/WRK8LnGMeB7x/y0v8PLyyvZ7WVqYEEIIIYTYNBS8hBBCCCHEpqHgtRJcXFwwZcoU9Uo4TjyX+P9mCfC6xDHiecT/N2uBQWuEEEIIIcSmoYWXEEIIIYTYNBS8hBBCCCHEpqHgJYQQQgghNg0FrwXx8OFDHD58GHfv3s3V91gzUtL15MmTOHPmjCr8kRkkUfXx48cRGBiI/IIUKDly5EiWy16HhoZiz549uH79OmydiIgINUY3btzIcoXFEydOIC4uDvmB8+fPq/+f+Pj4TG0fGxur3iP/p/K/lx949OiR+r+5d+9ept8TFBSkrt33799HfikaIP9vp0+fzvR7QkJC1Ln34MED5Bdu3rypzqXo6OgsvU/OI3mfXJ9IBmiIRTBt2jSNi4uLpkaNGup1yJAhmoSEBJO/x5o5fPiwpkyZMho/Pz9NsWLFNJUqVdKcPXs2w+2vX7+u6devn8bLy0tTr149TcGCBTWdOnXS3Lt3T2OrREdHa5577jmNm5ubplq1aur1u+++y9R7k5KSND169NDY29trxo8fr7FlFi5cqPHw8NBUrVpVvXbp0kUTHh7+2PcEBARo2rdvr86nRo0aaSpUqKDZuHGjxla5ceOGpk6dOhofHx9NuXLl1P/c9u3bH/ueZcuWaYoWLaqpWLGieq+7u7vmo48+0tgqly9f1rz66quaEiVKaOzs7DQ///xzpv7Pxo0bZ3TtfvPNNzW2ivwmffnll+qckP+dli1bPvE9p06dUv+ThQsX1tSvX1+dR3Itj4iI0NgqO3bs0HTr1k1TpEgRjUiz8+fPZ/q9cXFxmqZNm6pr9/Tp03O1n9YMBa8FsG7dOo2jo6Nm165dav7SpUuaQoUKaWbNmmXS91gzsbGxmrJly2qGDh2q5hMTEzXPPvuspmbNmuoHJD22bNmiWb58uX79w4cPlfCV99kqkydPVjcEd+7cUfP//POPungeOHDgie+Vc6dr165KqNiy4JWbJAcHB81vv/2m5oODg9WP8WuvvZbhe2JiYtS5Jj9Iuh9ded/SpUs1tkqrVq00HTt2VD+mgogy+TEODQ3N8GZLxNuUKVOMrlNy/u3fv19ji8jn++WXXzSRkZHqs2dG8Mr2BQoUUKJOdyMv7/3jjz80tojcSL711luaK1euaEaOHJkpwbtq1Sqjm0m52SxdurS6UbBV5syZo86ngwcPZlnwyvi+/PLL6qaUgjdjKHgtALHIPfXUU0bLRo0apX5gTfkea+bff/9VFwGx2uo4cuRIln9Mv/jiC2WBslXkgjd16lSjZbVq1VI/NI9DxrJUqVKau3fvaurWrWvTgvftt99WFktDZs6cqUSITtylZvHixcp64u/vr8kPyA20/G/JTaMOEfhyk71kyZJ03xMYGKjeYyhUQkJC1LI1a9ZobJ3MCt4WLVpoXnzxRaNlvXv3VjcXtk5mBW96jB07Vll7bR25AcqK4JX/N7lhDwsLo+B9AvThtQDER6lhw4ZGy5o0aaL84MQfzlTvsWbk8xYpUgTlypXTL2vQoAEcHBzUuswiPnOVKlWCLXLnzh3lQ5jeefG4MRI/y4EDB2Lu3LkoXrw4bJ2M/nfEp/fKlSvpvmfr1q2oX78+SpUqpfzHL168mGmfVmtEd74YjpP8/1WoUCHDc6lYsWJ44403MHnyZPz999/YsGEDBg8ejI4dO6Jr16551ndrPf+ych3Lj4j/r61eu7OLXO9fffVV/Pbbb/D09DR3dyweR3N3gGgDz+THxBCZl6AsCYaQHxJTvMeaSe/z2tnZoXDhwmpdZlixYoX6IV63bh1sEd04pHdePG6MXnvtNSVKnnnmGeQHZCzKly9vtEw3ZhmNk9xMuLm5oVmzZoiMjFTiWATvr7/+apNiTsZBbia9vLyydC699NJL2LFjByZNmgQPDw+17Q8//AAnJ6c86LXlExMTo4KR0vsflQAteeoq1zVizLfffoujR4/i+++/59AkI+eK/L8NHz4cLVq04LhkAlp4LQD5MZALoSG6CE1nZ2eTvceaSe/z6j5zZj6vWOjE2vTll1+ie/fusEV0oiK98yKjMVqzZo26AXjuuedUhK80EXQi8GTaFsnu/5uMhwg5sfBKFovnn39eNclsYWvI55WMKKmt2I87lyQLSps2bdC/f39cu3ZNReOL5alPnz7Yvn17HvXcev9HHR0dKXbT4c8//8Tbb7+NhQsXprGM52d+/vlnnDt3Du3atdNfu+X/VbI8HDx40Nzds0ho4bUAypYti9u3bxstk3l5ROHt7W2y91gz8nkljY/8Q+t+NCTllljaypQp89j3btu2Db169cKUKVPw1ltvwVYpXbo07O3t0z0vMhqjhIQE1KpVCx9//LF+maS4i4qKUqJ3586dytJnS2T0vyNkNE7iSiNPE/r166fmxQo3cuRIzJo1Swm7Vq1awdbGSJBzwNCNSOYzGiM5V+T/ccyYMfpl8uSgatWqWLt2Ldq3b4/8jvwviVtMeuefbsxJCsuWLcMrr7yixN0LL7zAoTFArkHyv/nBBx8YuaeJK5GkpbTVJ5k5gRZeC6BTp07qJBXxoeN///sfnnrqKf28iD25gxOrS2bfY0vI5xLf5M2bNxt9XrGKGP6Q7t271+jHRB6v9uzZU10UxLfQlnF3d1ePtsRqq0OstVu2bFHniw7xU9X5CxpadnVN/ORE2Mm0rYldQcZCPps8QjY8l2rXrq13BZKbKdlGBJzQpUsXNW2Y11iX77Jo0aKwNZo3b65cEgzPpf3796vrkOG5dOzYMVy9etVoHAzzgEquYnmPLY5RZpHx2Ldvn35exk9uAOSRtCBuaDJvOK75DTFkyP9bcHCwftny5cvVU7n58+fj5ZdfNmv/LAWx6MoTJkFcGVJfu+WmfNSoURS7GfGkqDaS+0h0c/HixVXmBYlmlvRIkj/1xIkTRnlD5euSqOfMvsfWGD58uKZkyZIqSnzBggUqR6NE3Bsi6aa++uorNX3o0CGVY1UioHfv3m3UbDmXo5OTk0pP9r///U9l8pAIXsMcs5La7XHZPGw9S4OkGJPMFZJ2S9K2SVYLOW/Wrl2r30bOEfl/k4hpQVLbtW3bVtOhQwe13Z9//qnyQD/zzDMaW0Vyp8r/z48//qjSr8l5lDqln+Qx1mUAkdSBEkVfu3ZtlY9XUiz17NlTpUu8deuWxhaRFG26a4qzs7PmnXfeUdMXL17UbyNpoiSDg2EGDMkJLnnT5do9aNAgjbe3t8p7bKtIFhgZF/l/kfMj9XVYssPI/9tff/1llHZzxIgRRtdtSdllq0gGGPmMkrZOxkLS1Mm8jI0OyZP+uGweTEv2eOjSYAGIVenAgQPKv1Sc8/38/JSlsm7dukbbtGzZUlk0M/seW0OCX3788Uf88ccf6tH99OnTMWzYMKNt5NGyjIUglqd69eqpCjSprbu2+KheaNu2rfKXlIwLhw4dUlbLJUuWoECBAvptKleu/NgqYZKNQKLxbRUXFxf1/X/xxReYM2eOChjatGmTevyuQ4K15P9NF/ksjw///fdffP311/juu+/U8vHjxyu3BltF/Cbl8bs8VpanK2JRmjBhgtE24lOpi5wX3155oiKBReJ3KedY9erV1by429gi4i+pu7Y0btxYb2mTJwIffvihWi6fXc4lw/8/sZaLO8w333yDihUrqmu5Lbs0fPXVV3rLf8GCBfVjprsOy7kjY6R7EiDX7qZNm+Ls2bNG1275X5WnMbaIjIX8vgkyFvJ7J4gbni6guGbNmo+9dsuY2er/mimwE9Vrkj0RQgghhBBigdCHlxBCCCGE2DQUvIQQQgghxKah4CWEEEIIITYNBS8hhBBCCLFpKHgJIYQQQohNQ8FLCCGEEEJsGgpeQgghhBBi01DwEkIsllu3buGff/7J8Tam5ODBg1ixYgVOnDiRZ8ckWtavX49r167l2nBIUn8paasr4b5q1SqjUuXZJbv7kbLD0p+YmJgc94GQ/A4FLyEkR2zZsgVLly5VbfXq1SYVgvv27VMVvgwrW6WutJR6m9zkhRdewMCBA7Fy5UpVBcqSEHF07Ngx9R3s3r0bDx48eOz2UpnxyJEjsCakut22bdueuF1oaKg6L+VG6Pjx48hsfSWpfCYCU1eF8eWXX8bhw4dz3O/s7kcqSspnmDFjRo77QEh+h6WFCSE54oMPPkBwcDAaNWqE6OhoJaTKly+PjRs3qlKgOUHKrT733HP6eRFykyZN0pfaTG+b3CIyMlKVzJWSzVJG1tIsn2PGjFHCTkpDP3r0CFeuXEHXrl0xb948fUny1OLOx8dHfW+2goj+adOmYebMmaqsuK+vr7oJcHd3x2+//fbY7y0sLAyff/65Ko9sSbz77rto3rw5Xn/9dRQqVMjc3SHEaqHgJYTkmKeeekoJK+H+/fuq5vvUqVMxZ84ctUysoZcuXVICpFmzZnoLmo6HDx/i6NGjyqIlAszLy0stL1WqFLp166amAwMDceDAAfV4V6zJQq1atYy2MeRxx5TH4rK+e/fuOH36tHrcXKdOnQzr0AcEBCjLqc6ifPXqVXTp0gUhISFqP3J86dvdu3fRs2dPuLq6Ij4+Hvv371eWVhmPKlWqGO1TxHuBAgVQoUIFZYWUz9W2bVu4ubmp/cpxZH2LFi3g5OSU4diLxVNuAESsyc2AjKGQkJCAJUuWqMfzqQWvWHblM4klVDeWIo5FUD2u3/7+/up76t27t15gintHtWrVULduXbVMxubixYtqbOVzybRQuHBhNcbFixdPdxzkJkmOK59VzidBxlNuMOQ71u3/cXz00UeYPXs2/vvvP7Rs2VI/Dm+88Ybap4jfihUrpvteEcTlypVTNwypkbE6deoUvL291blkZ2dntF7GUb4vOZasL1q0aLrHkM9z8uRJdX43bdpU/13p3B7kvbIPGTc5L2S8pFWqVAmLFi3ChAkTnjgGhJAM0BBCSA5o2rSpZuTIkUbLevfurWnVqpUmMTFRM2DAAI2Xl5ema9eumtKlS2vq1KmjuXv3rn7bf//9V1OwYEFNmzZtNF26dNFUqFBBLRP++usvTZEiRdT0iRMnNM2aNdO4urqqfUpbsWKF0TZCZo75888/q/e0bNlS07p1a02HDh00Li4uan/pceDAAc3TTz8tz8U1PXr0UPu/evWqfj/SL9mXLH/06JHm+vXrmsqVK6vPIp+pQIECmuHDhxvtU/bToEED1T+ZLleunGoLFizQlC1bVi2TdbLfhISEDMe/YcOGauyywi+//KLx8/PTVKxYUT+WN27ceGK/z5w5o8bg3r17av7IkSNqvlu3bvptxo4dq3nuuefU9OLFi/X7b9++vcbDw0Mzf/78NOMg51D58uU13bt313zwwQdq+apVqzRubm6aFi1aqNaoUSNN0aJF1Zinx/3799V3+OGHH6ZZFxMToz7vkCFDMhwT+bwTJkwwWib97dy5s3qvfMbChQurcyouLi7N+du4cWP1PUiff/311zT7kffJeMt5VKxYMU3Hjh3VuWq4jfRBzoFnn31W8+eff+rXSb9ke0JI9qHgJYSYVPAmJSVpatWqpenXr58SViIGRBwKERERSqC99NJL+u1FGH/00Uf6eRGMW7duVdOpxeySJUuUWDAk9TaZOaaIJhFqv//+u36ZCK0qVapk+DlFDMp7Ll++nGY/P/74o9G2ImpERMfGxqr5U6dOaZydnZWIMxR6IqBu376t5sPCwjTe3t7q8wUGBqplwcHBGnd3d83//ve/dPsk6+X43377rSar9OnTJ82NSmb67evrq1m2bJmanjlzphKinp6emvj4eLVMvvvvvvsu3WPK9yqCUMSp4TiI2Lty5Yp+mXxncpzp06frl33yySfqs2YkeFeuXKnWy41ReowaNUpTokSJDMdDxPRPP/1ktEz6ValSJc3Dhw/VvL+/vzrXfvjhB30/ixcvrhfpwrx589RnlG0N9yPneXR0tJoPCAhQ4lx3Y6fbRm6AZJ+pEQEt5zQhJPswaI0QkmPkMbY8GpfHrn379lWPsSdOnKiWSZCXPJ4VPDw81ONlCQySx+GCPMK/fPkyoqKi1Lw87u3QoUO2+5KZYwoFCxZUQWg62rVrpz6HLkI/s4j7wogRI4x8fdetW6fcC5ydndWy2rVro1evXnr3AR1PP/00SpYsqaY9PT2VC4G4JxQrVkwtEx9ocSkQ14z0EDcPoUyZMsgpme13mzZtsH37djUt/q5Dhw5VrhDi6iBuEOLiIWNp6K4i28v4BwUFqe9A3EgMEfcHQ1cD2V7cOiRITYecT+n5Imd2LGT5vXv3Mny/9F1cFlIzbNgw/XI/Pz91zshn0X1++UzvvPOOfnsJoJTvcs2aNUb7eeWVV9S5IoiLRuXKlfXuHjpkLOV8TY0cX3yMxd2EEJI9KHgJITnm+vXrysdVBID41Z47d075I0pWBZ3w1CHCJjY2Vi8+JHjqxo0byte2U6dO+O6773KUhikzxxRSixsXFxcldrMqKqTfhr6YcnwhvT7o1j2uD+kty2g8RFgJT8rIkBky228RsyJIRbju2bNHzYvvsSyT7198deUcEH799VcVVDh58mR9Fg95sigi0ZASJUqkSTUnol9uhnRI4JnuRiA9xA/4cWMhgZXpiUkdsk5Ef2rEr9cQ8TXWjYe8yvevO7Yg54LhNjpkXJ70vaYeBx3SL9n+cb7chJDHw6A1QohJg9YMkSwAYuEzROZFFOgEgFg1JeBHxKgEYH388cdKOP3999/Z6ktmjmlKUgcwyfF1x0zdB906UyFBdhIEJgFzYonMCZnttwjcsWPH4t9//1UWSwlYk2USvCbBfyJ+ZUwk+GrcuHFK9D7//PPqvbJMRGzqNGGpx1As25JpIjVi9c2IJk2aqFcJfJMgr9TIGEmgWEaIJV1u3J50TJnXjYe8Sj/l8xh+hux+16nHQYf0q2rVqlneHyEkBVp4CSG5RqtWrVTeXEM3ARFGkolBLFaCLiG/WO9EGMmjaxEn6SGWtCdZfzNzzNxELH4ingwFu/RZ3AWkb6ZEBJK4ayxevFhF9qeXFSAjF43UY5nZfssNimwr6b90rgvyKtZeyX2rWyaP4CVNnaFQEwuviN4nIcJUji3707Fp0ya920t6VK9eXWXO+OSTTxAeHm60TnLZyk2VnFsZ0bFjR5VSLzW67ByCWLVlXpcBQvop4yviX4dkYZCUcLptTIH0S5e5ghCSPWjhJYTkGvIo+6+//kLnzp0xYMAAlXxf5g2FjM7fVnKNSqWrb7/9Fv369Ut3f5JbVR7vTpkyRQkc3aPzrB4zt5HPIL644kYhAlEEqQhMyaVqasTnVnygW7dujVdffRUNGjRQabJEeO3atQtnzpxJkwZOkBuAzz77DD///LNyjZC0ZJntt/jxSvENXcEPcXsQ6/mFCxf0glfmpU+yzWuvvabcFOQpgM4/+HGIG4HkFZZzQ75PsaCKq4u4NTwO6a+kiGvYsKHyqxZhLmnNxMo8ffr0dNPX6RALuXxm8QU2TJ0mvslyI9a+fXsldu/cuYO3335brRN3DRn/F198US0T67XkAJbtJZ2cKRBXDHEX+frrr02yP0LyK7TwEkJyhPjdZpTQX0SP5D6VIDSxAEpwk+SANbQYyo+5WK9EoElw1pdffqn8etMrKiFCSApaiJ+mWHFFYKXeJjPHFIEm+XINkdypIpDTE4c6H09Zr/ObzWg/gggrebQu+xLLouStFeFl6EMqolHEqSEiqlLnm5XxFSGWEeKqIaJVxlEC/uRV/EdFeEoAmS5QKjUjR45UeWulVLIIORHJmem3MHjwYDUWclOhQyzNstzwJmTt2rXo06ePyrUrFtudO3eqqmPynT1uHAQReF999ZX6DOI2IN+7iOeM8ujqnhJIf8XKK24AcpMjPtFy0yPC+XFIEJkEXM6dO1e/TOYl+Ez6KOeQfDZ5NfS1lfzHCxcuVH7oEown85LT1xDZjwS8GSLW6Bo1ajx2G0FuEiS40XBbQkjWsZNUDdl4HyGEEGJTiHuNFEwRkZnRjU9eIi4Uo0ePVmI9dfAcISRrUPASQgghhBCbhi4NhBBCCCHEpqHgJYQQQgghNg0FLyGEEEIIsWkoeAkhhBBCiE1DwUsIIYQQQmwaCl5CCCGEEGLTUPASQgghhBCbhoKXEEIIIYTYNBS8hBBCCCHEpqHgJYQQQgghNg0FLyGEEEIIsWkoeAkhhBBCiE1DwUsIIYQQQmwaCl5CCCGEEGLTUPASQgghhBCbhoKXEEIIIYTAlvk/M4nBP9/izOUAAAAASUVORK5CYII=", + "text/plain": "
" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "profile_a = ar.get_proatomic_density_profile(atom_a)\n", + "profile_b = ar.get_proatomic_density_profile(atom_b)\n", + "assert profile_a is not None and profile_b is not None\n", + "positions = [distance * index / 500 for index in range(501)]\n", + "rho_a_values = [profile_a(x, radius_unit=\"bohr\") for x in positions]\n", + "rho_b_values = [profile_b(distance - x, radius_unit=\"bohr\") for x in positions]\n", + "rho_sum_values = [left + right for left, right in zip(rho_a_values, rho_b_values)]\n", + "\n", + "figure, axis = plt.subplots(figsize=(7.0, 4.2))\n", + "axis.plot(positions, rho_a_values, label=r\"$\\rho_C(x)$\")\n", + "axis.plot(positions, rho_b_values, label=r\"$\\rho_O(R-x)$\")\n", + "axis.plot(positions, rho_sum_values, label=\"sum\", linewidth=2)\n", + "axis.axvline(boundary.position_from_a, color=\"black\", linestyle=\"--\", label=\"boundary\")\n", + "axis.axvline(minimum.position_from_a, color=\"black\", linestyle=\":\", label=\"minimum\")\n", + "axis.set(xlabel=\"Position from C toward O (bohr)\", ylabel=r\"Density (electron/bohr$^3$)\", title=\"C–O component and summed line densities\")\n", + "axis.set_yscale(\"log\")\n", + "axis.legend()\n", + "figure.tight_layout()\n", + "print(\"The component crossing and summed-density minimum are marked separately.\")\n", + "figure" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Exact homonuclear midpoint\n", + "\n", + "For identical atoms, exchange symmetry fixes both modes at exactly `R/2`; raw interpolation-scale shell structure is not allowed to choose an off-centre separator." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "O–O boundary: method=homonuclear_midpoint; x_A=1.5 bohr\n", + "O–O minimum: method=homonuclear_midpoint; x_A=1.5 bohr\n" + ] + } + ], + "source": [ + "homonuclear_distance = 3.0\n", + "o_o_boundary = ar.estimate_proatomic_boundary(\"O\", \"O\", homonuclear_distance, distance_unit=\"bohr\")\n", + "o_o_minimum = ar.estimate_promolecular_density_minimum(\"O\", \"O\", homonuclear_distance, distance_unit=\"bohr\")\n", + "assert o_o_boundary is not None and o_o_minimum is not None\n", + "assert o_o_boundary.position_from_a == homonuclear_distance / 2\n", + "assert o_o_minimum.position_from_a == homonuclear_distance / 2\n", + "print(f\"O–O boundary: method={o_o_boundary.method}; x_A={o_o_boundary.position_from_a} bohr\")\n", + "print(f\"O–O minimum: method={o_o_minimum.method}; x_A={o_o_minimum.position_from_a} bohr\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Low-density gap\n", + "\n", + "At a long H–O separation the two `1e-4 electron/bohr^3` contours no longer overlap. Boundary mode returns the geometric midpoint of the contour gap. Minimum mode does not search unconstrained tails and explicitly returns a `low_density_gap` non-result." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "boundary: method=cutoff_gap_midpoint; status=low_density_gap; x_A=5.897488624776 bohr\n", + "minimum: method=none; status=low_density_gap; x_A=None\n", + "cutoff-contour separation: 3.538953155071 bohr\n" + ] + } + ], + "source": [ + "gap_boundary = ar.estimate_proatomic_boundary(\"H\", \"O\", 12.0, distance_unit=\"bohr\")\n", + "gap_minimum = ar.estimate_promolecular_density_minimum(\"H\", \"O\", 12.0, distance_unit=\"bohr\")\n", + "assert gap_boundary is not None and gap_minimum is not None\n", + "print(f\"boundary: method={gap_boundary.method}; status={gap_boundary.status}; x_A={gap_boundary.position_from_a:.12f} bohr\")\n", + "print(f\"minimum: method={gap_minimum.method}; status={gap_minimum.status}; x_A={gap_minimum.position_from_a}\")\n", + "print(f\"cutoff-contour separation: {gap_boundary.contour_separation:.12f} bohr\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## No resolved interior minimum\n", + "\n", + "A short H–U example shows another valid diagnostic result. The cutoff-bounded search finds no strict-interior valley resolved at the declared `0.01 bohr` scale, so minimum mode returns typed status and provenance without inventing a coordinate or falling back to boundary mode." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "method=none; status=no_resolved_interior_minimum; x_A=None\n", + "resolution=0.01 bohr; converged=True; passes=2\n", + "dataset=pbe0_sfx2c_dyallv4z_h-lr_neutral_v2\n", + "interpolation=loglog_positive_bracketed_v1\n", + "pairwise contract=neutral_proatom_pairwise_cutoff_1e-4_resolution_0.01_v1\n" + ] + } + ], + "source": [ + "unresolved = ar.estimate_promolecular_density_minimum(\"H\", \"U\", 1.58, distance_unit=\"bohr\")\n", + "assert unresolved is not None\n", + "print(f\"method={unresolved.method}; status={unresolved.status}; x_A={unresolved.position_from_a}\")\n", + "print(f\"resolution={unresolved.search_resolution} bohr; converged={unresolved.search_converged}; passes={unresolved.search_passes}\")\n", + "print(f\"dataset={unresolved.dataset_id}\")\n", + "print(f\"interpolation={unresolved.interpolation_contract}\")\n", + "print(f\"pairwise contract={unresolved.pairwise_contract}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What this demonstrated: mode selection, statuses, and limitations\n", + "\n", + "Use `boundary`—the dispatcher default—for a stable neutral-proatom divider in geometry, Voronoi/Laguerre calibration, or related reference-atom workflows. Use `minimum` only when a cutoff-bounded promolecular line-density valley is the intended proxy and a resolution-limited non-result is acceptable. Minimum mode never silently falls back to boundary mode.\n", + "\n", + "Pair distances must satisfy `0 < R <= 20 bohr`; distance and density units are independent, and coordinates are reported from atom A toward atom B. Reversing A/B maps primary and alternative coordinates `x` to `R - x`, swaps component fields, and relabels dominance; orientation-independent status and diagnostics remain equivalent. The fixed per-atom cutoff is exactly `1e-4 electron/bohr^3`. The practical minimum resolution is `0.01 bohr`; sub-resolution minima are deliberately coalesced or rejected. Inspect `method`, `status`, `position_from_a`, `dominant_atom`, `ambiguous`, and search diagnostics instead of assuming every valid request has a coordinate. Explicit statuses include `low_density_gap`, `one_atom_dominates`, `no_resolved_interior_minimum`, `boundary_dominated`, `ambiguous_competing_minima`, and `search_unstable`.\n", + "\n", + "The profiles cover neutral H–Lr only and are defined by the recorded PBE0/sf-X2C/dyall-v4z method, finite radial grid, sphericalization, and interpolation contract. The package provides no ions, vectorized or grid-density APIs, environment-dependent atoms, molecular densities, or exact QTAIM surfaces." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/mkdocs.yml b/mkdocs.yml index e0952f2..c8cf96c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,13 +8,36 @@ theme: plugins: - search + - mkdocs-jupyter: + execute: false + allow_errors: false + include_source: true + cache: false + custom_mathjax_url: https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js?config=TeX-AMS_CHTML-full,Safe - mkdocstrings: handlers: python: paths: [src] options: + docstring_style: google show_root_heading: true show_source: false + show_signature: true + separate_signature: true + show_signature_annotations: true + signature_crossrefs: true + merge_init_into_class: true + annotations_path: brief + show_docstring_parameters: true + show_docstring_returns: true + show_docstring_raises: true + show_docstring_attributes: true + show_docstring_examples: true + docstring_options: + returns_multiple_items: false + filters: + - "!^_[^_]" + - "!^__post_init__$" nav: - Home: index.md @@ -23,6 +46,7 @@ nav: - Quickstart: guide/quickstart.md - Policies: guide/policies.md - Custom sets: guide/custom_sets.md + - Proatomic density: guide/proatomic_density.md - Non-goals: guide/non_goals.md - Datasets: - Overview: datasets/index.md @@ -32,19 +56,23 @@ nav: - X–H bond length: datasets/xh_bond_length.md - Notebooks: - Overview: guide/notebooks.md - - Quickstart notebook: notebooks/01-quickstart.md - - Policies and assessment notebook: notebooks/02-policies-and-assessment.md - - Custom sets and discovery notebook: notebooks/03-custom-sets-and-discovery.md + - Quickstart notebook: notebooks/01-quickstart.ipynb + - Policies and assessment notebook: notebooks/02-policies-and-assessment.ipynb + - Custom sets and discovery notebook: notebooks/03-custom-sets-and-discovery.ipynb + - IAS method-selection study: notebooks/04-ias-method-selection-study.ipynb + - Proatomic density and IAS: notebooks/05-proatomic-density-and-ias.ipynb - Development: - Architecture: dev/architecture.md - Data curation: dev/data_curation.md - - Development plan: dev/dev_plan.md + - IAS method selection: dev/ias_method_selection.md - API: - Overview: api/index.md - atomref: api/atomref.md - atomref.elements: api/elements.md + - atomref.errors: api/errors.md - atomref.registry: api/registry.md - atomref.transfer: api/transfer.md - atomref.policy: api/policy.md + - atomref.proatoms: api/proatoms.md - atomref.radii: api/radii.md - atomref.xh: api/xh.md diff --git a/notebooks/01-quickstart.ipynb b/notebooks/01-quickstart.ipynb deleted file mode 100644 index 47b58d1..0000000 --- a/notebooks/01-quickstart.ipynb +++ /dev/null @@ -1,89 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# atomref quickstart\n\nThis notebook covers the main public API: element helpers, direct\n`get_*` calls, provenance-carrying `lookup_*` calls, and packaged dataset\ndiscovery.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import atomref as ar\n", - "\n", - "print(ar.get_element('Cl'))\n", - "print(ar.list_quantities())\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "r_c = ar.get_covalent_radius('C')\n", - "r_vdw = ar.get_vdw_radius('O')\n", - "print(r_c)\n", - "print(r_vdw)\n", - "assert r_c == 0.76\n", - "assert r_vdw == 1.50\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "lookup = ar.lookup_vdw_radius('Pm')\n", - "print(f\"{lookup.value:.12f}\")\n", - "print(lookup.source)\n", - "print(lookup.resolved_from)\n", - "assert lookup.source == 'transfer_linear'\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "quantity = ar.get_quantity_info('atomic_radius')\n", - "print(quantity.quantity, quantity.domain, quantity.units)\n", - "\n", - "for info in ar.list_dataset_infos('van_der_waals_radius', usage_role='target'):\n", - " print(info.ref.set_id, info.name, info.usage_role)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vdw = ar.get_radii_set('van_der_waals', 'alvarez2013')\n", - "print(vdw.get('O'))\n", - "\n", - "support = ar.get_builtin_set(ar.DatasetRef('atomic_radius', 'rahm2016'))\n", - "print(support.get('Pm'))\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/02-policies-and-assessment.ipynb b/notebooks/02-policies-and-assessment.ipynb deleted file mode 100644 index dfe2678..0000000 --- a/notebooks/02-policies-and-assessment.ipynb +++ /dev/null @@ -1,97 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Policies and assessment\n", - "\n", - "This notebook shows how `atomref` resolves missing values through ordered\n", - "policy steps and how to inspect policy-level behavior.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import atomref as ar\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "covalent_policy = ar.RadiiPolicy(\n", - " kind='covalent',\n", - " base_set='cordero2008',\n", - " transfers=(\n", - " ar.SubstitutionTransfer(\n", - " source=ar.DatasetRef('covalent_radius', 'csd_legacy_cov')\n", - " ),\n", - " ),\n", - ")\n", - "lookup = ar.lookup_covalent_radius('Bk', policy=covalent_policy)\n", - "print(lookup.source)\n", - "print(f\"{lookup.value:.12f}\")\n", - "print(lookup.resolved_from)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "vdw_policy = ar.RadiiPolicy(\n", - " kind='van_der_waals',\n", - " base_set='alvarez2013',\n", - " transfers=(\n", - " ar.LinearTransfer(\n", - " predictors=(ar.DatasetRef('atomic_radius', 'rahm2016'),)\n", - " ),\n", - " ),\n", - ")\n", - "lookup = ar.lookup_vdw_radius('Pm', policy=vdw_policy)\n", - "print(f\"{lookup.value:.12f}\")\n", - "print(lookup.source)\n", - "print(\n", - " f\"slope={lookup.fit.coefficients[0]:.12f} intercept={lookup.fit.intercept:.12f} n={lookup.fit.n_points}\"\n", - ")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "assessment = ar.assess_radii_policy(\n", - " ['C', 'Xe', 'Pm', 'Bk'],\n", - " policy=vdw_policy,\n", - " detail=True,\n", - ")\n", - "print(assessment.n_base, assessment.n_transfer_linear, assessment.n_missing)\n", - "for row in assessment.per_element:\n", - " value = 'None' if row.lookup.value is None else f\"{row.lookup.value:.12f}\"\n", - " print(row.symbol, row.lookup.source, value)\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/03-custom-sets-and-discovery.ipynb b/notebooks/03-custom-sets-and-discovery.ipynb deleted file mode 100644 index 58f9d92..0000000 --- a/notebooks/03-custom-sets-and-discovery.ipynb +++ /dev/null @@ -1,79 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Custom sets and dataset discovery\n", - "\n", - "This notebook shows how to define a small user-provided set, plug it into a\n", - "policy, and inspect the packaged dataset catalog.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import atomref as ar\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "custom_cov = ar.ElementScalarSet.from_mapping(\n", - " ref=ar.DatasetRef(\"covalent_radius\", \"demo_user_cov\"),\n", - " values={\"C\": 0.77, \"O\": 0.67},\n", - " name=\"Demo user covalent set\",\n", - " units=\"angstrom\",\n", - " description=\"Example custom set for notebook usage.\",\n", - " notes=(\"Notebook example\",),\n", - ")\n", - "\n", - "policy = ar.RadiiPolicy(\n", - " kind=\"covalent\",\n", - " base_set=custom_cov,\n", - " transfers=(\n", - " ar.SubstitutionTransfer(\n", - " source=ar.DatasetRef(\"covalent_radius\", \"cordero2008\")\n", - " ),\n", - " ),\n", - ")\n", - "\n", - "for symbol in (\"C\", \"O\", \"N\"):\n", - " print(symbol, ar.lookup_covalent_radius(symbol, policy=policy))\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for info in ar.list_radii_set_infos(\"van_der_waals\", usage_role=\"target\"):\n", - " print(info.ref.set_id, info.semantic_class, info.origin_class, info.phase_context)\n", - "\n", - "rahm = ar.get_dataset_info(ar.DatasetRef(\"atomic_radius\", \"rahm2016\"))\n", - "print(rahm.name)\n", - "print(rahm.semantic_class, rahm.phase_context, rahm.usage_role)\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pyproject.toml b/pyproject.toml index b712101..6caed2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,24 @@ build-backend = "hatchling.build" [project] name = "atomref" dynamic = ["version"] -description = "Curated atomic reference data and transfer policies for geometry and structure-analysis algorithms." +description = "Curated atomic reference data, proatomic densities, and transfer policies for structure algorithms." readme = "README.md" requires-python = ">=3.10" license = { file = "LICENSE" } authors = [ { name = "Ivan Yu. Chernyshov", email = "ivan.chernyshoff@gmail.com" } ] -keywords = ["chemistry", "materials", "crystallography", "reference data", "atomic radii"] +keywords = [ + "chemistry", + "materials", + "crystallography", + "reference data", + "atomic radii", + "proatomic density", + "electron density", + "interatomic surfaces", + "IAS", +] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -25,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Typing :: Typed", ] @@ -36,24 +47,51 @@ Documentation = "https://delonecommons.github.io/atomref/" Repository = "https://github.com/DeloneCommons/atomref" Issues = "https://github.com/DeloneCommons/atomref/issues" Changelog = "https://github.com/DeloneCommons/atomref/blob/main/CHANGELOG.md" +Citation = "https://github.com/DeloneCommons/atomref/blob/main/CITATION.cff" [project.optional-dependencies] test = [ "pytest>=7", "tomli>=2; python_version < '3.11'", ] +notebooks = [ + "ipykernel>=6.29", + "matplotlib>=3.8", + "mkdocs>=1.6,<2", + "mkdocs-jupyter>=0.26,<0.27", + "nbclient>=0.10,<0.12", + "nbformat>=5.10,<6", +] docs = [ "mkdocs>=1.6,<2", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.25", - "mkdocs-include-markdown-plugin>=6.2", "pymdown-extensions>=10.0", - "tomli>=2; python_version < '3.11'", ] dev = [ "build>=1.2", "twine>=5", "flake8>=7", + "mypy>=2.3,<3", + "cffconvert==2.0.0", +] +all = [ + "pytest>=7", + "tomli>=2; python_version < '3.11'", + "ipykernel>=6.29", + "matplotlib>=3.8", + "mkdocs>=1.6,<2", + "mkdocs-jupyter>=0.26,<0.27", + "nbclient>=0.10,<0.12", + "nbformat>=5.10,<6", + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.25", + "pymdown-extensions>=10.0", + "build>=1.2", + "twine>=5", + "flake8>=7", + "mypy>=2.3,<3", + "cffconvert==2.0.0", ] [tool.hatch.version] @@ -64,6 +102,7 @@ packages = ["src/atomref"] include = [ "src/atomref/data/*.csv", "src/atomref/data/*.json", + "src/atomref/data/*.zip", ] [tool.hatch.build.targets.sdist] @@ -72,11 +111,12 @@ include = [ "/tests", "/docs", "/tools", - "/notebooks", + "/.flake8", "/mkdocs.yml", "/README.md", "/CHANGELOG.md", "/DEV_PLAN.md", + "/CITATION.cff", "/NOTICE.md", "/LICENSE", "/COPYING", @@ -95,3 +135,7 @@ norecursedirs = [ ".eggs", "*.egg-info", ] + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/src/atomref/__about__.py b/src/atomref/__about__.py index bbab024..3ced358 100644 --- a/src/atomref/__about__.py +++ b/src/atomref/__about__.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.2.1" diff --git a/src/atomref/__init__.py b/src/atomref/__init__.py index fb569b3..905d681 100644 --- a/src/atomref/__init__.py +++ b/src/atomref/__init__.py @@ -1,4 +1,4 @@ -"""Public package exports for :mod:`atomref`.""" +"""Public package exports for `atomref`.""" from .__about__ import __version__ from .elements import ( @@ -9,6 +9,24 @@ is_valid_element_symbol, ) from .policy import LookupResult, ValuePolicy, get_value, lookup_value +from .proatoms import ( + BOHR_TO_ANGSTROM, + DEFAULT_PROATOMIC_DENSITY_SET, + IAS_MINIMUM_RESOLUTION_BOHR, + PROATOMIC_TAIL_CUTOFF, + IASPositionResult, + ProatomicDensityProfile, + ProatomicDensitySet, + estimate_ias_position, + estimate_proatomic_boundary, + estimate_promolecular_density_minimum, + get_proatomic_density, + get_proatomic_density_profile, + get_proatomic_density_set, + get_proatomic_density_set_info, + list_proatomic_density_set_infos, + list_proatomic_density_sets, +) from .radii import ( DEFAULT_COVALENT_POLICY, DEFAULT_VDW_POLICY, @@ -36,9 +54,11 @@ lookup_xh_bond_length, ) from .registry import ( + BuiltinSet, CoverageInfo, DatasetInfo, DatasetRef, + ElementRadialSet, ElementScalarSet, QuantityInfo, Reference, @@ -58,9 +78,11 @@ "get_element", "iter_elements", "is_valid_element_symbol", + "BuiltinSet", "CoverageInfo", "DatasetInfo", "DatasetRef", + "ElementRadialSet", "ElementScalarSet", "QuantityInfo", "Reference", @@ -77,6 +99,22 @@ "ValuePolicy", "lookup_value", "get_value", + "BOHR_TO_ANGSTROM", + "DEFAULT_PROATOMIC_DENSITY_SET", + "PROATOMIC_TAIL_CUTOFF", + "IAS_MINIMUM_RESOLUTION_BOHR", + "IASPositionResult", + "ProatomicDensityProfile", + "ProatomicDensitySet", + "estimate_proatomic_boundary", + "estimate_promolecular_density_minimum", + "estimate_ias_position", + "list_proatomic_density_sets", + "list_proatomic_density_set_infos", + "get_proatomic_density_set", + "get_proatomic_density_set_info", + "get_proatomic_density_profile", + "get_proatomic_density", "RadiiPolicy", "RadiiElementAssessment", "RadiiPolicyAssessment", diff --git a/src/atomref/data/proatomic_density_neutral.zip b/src/atomref/data/proatomic_density_neutral.zip new file mode 100644 index 0000000..5df06e5 Binary files /dev/null and b/src/atomref/data/proatomic_density_neutral.zip differ diff --git a/src/atomref/data/registry.json b/src/atomref/data/registry.json index e6e4469..c248bdc 100644 --- a/src/atomref/data/registry.json +++ b/src/atomref/data/registry.json @@ -28,6 +28,11 @@ "domain": "element", "units": "angstrom", "description": "Element-indexed reference X-H bond lengths keyed by parent element X and intended for hydrogen-position normalisation or related geometry workflows." + }, + "proatomic_density": { + "domain": "element", + "units": "electron/bohr^3", + "description": "Element-indexed neutral spherical proatomic density profiles sampled on a shared radial grid." } }, "datasets": { @@ -40,6 +45,7 @@ "phase_context": "condensed_phase", "method_summary": "Derived from crystallographic bond distances (primarily single bonds) across the periodic table.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "covalent.csv", "column": "cordero2008" @@ -78,6 +84,7 @@ "phase_context": "mixed_or_legacy", "method_summary": null, "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "covalent.csv", "column": "csd_legacy_cov" @@ -121,6 +128,7 @@ "phase_context": "mixed_or_legacy", "method_summary": "Bondi compiled van der Waals radii from a combination of experimental sources (e.g., crystal structures, liquid-state properties, gas kinetic data) to reproduce molecular/atomic volumes and sizes. This set is widely used as a historical reference and in many computational chemistry defaults.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "bondi1964" @@ -206,6 +214,7 @@ "phase_context": "condensed_phase", "method_summary": "Rowland & Taylor analyzed distributions of intermolecular nonbonded contact distances in organic crystal structures from the Cambridge Structural Database (CSD). They fitted/estimated characteristic contact distances and solved for per-element radii by least-squares analysis over many element-pair distance distributions.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "rowland_taylor1996" @@ -255,6 +264,7 @@ "phase_context": "condensed_phase", "method_summary": null, "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "alvarez2013" @@ -301,6 +311,7 @@ "phase_context": "condensed_phase", "method_summary": "Chernyshov et al. introduce a line-of-sight (LoS) criterion to identify 'direct' interatomic contacts in complex molecular crystals. vdW radii are then inferred from statistically analyzed contact-distance distributions for specific atom types, yielding radii (including R_half and R_max variants) intended to better reflect steric/anistropic effects than simple distance-based heuristics.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "chernyshov2020" @@ -351,6 +362,7 @@ "phase_context": "mixed_or_legacy", "method_summary": null, "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "csd_legacy_vdw" @@ -405,6 +417,7 @@ "phase_context": "isolated_atom", "method_summary": "Rahm et al. computed relativistic all-electron DFT electron densities (close to the basis-set limit) for isolated atoms and ions. Radii are defined by an electron-density threshold, producing a consistent, theory-based size measure that correlates well with structural van der Waals radii derived from crystal structures.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "van_der_waals.csv", "column": "rahm2016" @@ -452,6 +465,7 @@ "phase_context": "condensed_phase", "method_summary": "Sparse parent-element target set for hydrogen normalisation. ConQuest moves H along the experimentally determined X-H vector to these neutron-derived distances.", "storage": { + "kind": "element_scalar_csv", "format": "dense_by_z_csv", "filename": "xh_bond_length.csv", "column": "csd_legacy_xh_cno" @@ -501,6 +515,83 @@ ], "usage_role": "target" } + }, + "proatomic_density": { + "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2": { + "name": "Neutral H-Lr proatomic densities (PBE0/sf-X2C/dyall-v4z, upstream v2)", + "description": "Neutral spherical proatomic reference densities for H through Lr derived from the atomref-proatoms 2.0.0 PBE0/sf-X2C/dyall-v4z dataset and truncated to a 20-bohr public domain with one retained source bracketing point.", + "semantic_class": "proatomic_density_radial", + "origin_class": "computed_theoretical", + "phase_context": "isolated_atom", + "method_summary": "PBE0 self-consistent spherical fractional-occupation UKS with spin-free one-electron X2C and the dyall-v4z basis.", + "storage": { + "kind": "element_radial_csv_zip", + "format": "wide_csv_zip", + "filename": "proatomic_density_neutral.zip", + "member": "proatomic_density_neutral.csv", + "radius_column": "r_bohr", + "density_column_pattern": "z{z:03d}", + "native_coordinate_unit": "bohr", + "native_density_unit": "electron/bohr^3", + "public_max_radius_bohr": 20.0, + "retained_bracketing_radius_bohr": 20.1644204667093, + "retained_rows": 1127, + "interpolation_contract": "loglog_positive_bracketed_v1", + "monotonicity_relative_tolerance": 1e-12, + "charge_scope": "neutral atoms only", + "source_project": "atomref-proatoms", + "source_release": "2.0.0", + "source_archive_commit": "5b330d8215eea95b273e38a6f4962ecf917e71e6", + "source_dataset_id": "pbe0_sfx2c_dyallv4z_h-lr_spherical_v2", + "source_profiles_sha256": "b5520ab009542d52098dd6dbb920966d8d13377a4a5004f584a7bd15cd41c299", + "source_metadata_sha256": "32c833ca69fa0f7eb9ed32841aafc638123ff872861e636156610e417fc4c514", + "basis_id": "dyall-v4z", + "basis_sha256": "0ee543855f8b1e7fbe9868d4abb844d8e8cc8b8c2694067b2b40de014bb4be94", + "profile_data_version": "2.0.0", + "electronic_method": "PBE0", + "scf_model": "self-consistent spherical fractional-occupation UKS", + "relativity": "spin-free one-electron X2C", + "data_license": "CC BY 4.0", + "data_license_url": "https://creativecommons.org/licenses/by/4.0/", + "concept_doi": "10.5281/zenodo.21291021", + "version_doi": "10.5281/zenodo.21291022" + }, + "coverage": { + "n_values": 103, + "z_min": 1, + "z_max": 103, + "has_placeholders": false + }, + "placeholder_value": null, + "extraction_source": "Deterministic neutral, truncated consumer snapshot built from atomref-proatoms 2.0.0 dataset pbe0_sfx2c_dyallv4z_h-lr_spherical_v2.", + "aliases": [ + "neutral H-Lr PBE0 proatomic densities", + "atomref-proatoms neutral v2" + ], + "references": [ + { + "authors": "Ivan Yu. Chernyshov", + "year": 2026, + "title": "atomref-proatoms: spherical atomic and ionic reference densities", + "doi": "10.5281/zenodo.21291021", + "note": "Concept DOI; cite with atomref-proatoms release 2.0.0 and the exact dataset ID." + }, + { + "authors": "Ivan Yu. Chernyshov", + "year": 2026, + "title": "atomref-proatoms 2.0.0", + "doi": "10.5281/zenodo.21291022", + "note": "Version-specific DOI for the immutable source archive." + } + ], + "notes": [ + "These method-, basis-, state-, and sphericalization-defined reference densities are not unique basis-independent atomic observables.", + "The public validity domain is 0 <= r <= 20 bohr; the retained point at 20.1644204667093 bohr is an internal interpolation bracket only.", + "Detailed state and generation metadata remain available in the exact atomref-proatoms 2.0.0 archive.", + "The imported data are licensed CC BY 4.0 separately from atomref code." + ], + "usage_role": "target" + } } } } diff --git a/src/atomref/elements.py b/src/atomref/elements.py index 5245b80..019f83f 100644 --- a/src/atomref/elements.py +++ b/src/atomref/elements.py @@ -3,6 +3,7 @@ from __future__ import annotations import csv +import io import re from dataclasses import dataclass from functools import lru_cache @@ -15,7 +16,17 @@ @dataclass(frozen=True, slots=True) class Element: - """Chemical element identity keyed by atomic number and symbol.""" + """Chemical element identity keyed by atomic number and symbol. + + Attributes: + z: Atomic number. Packaged elements span 1 (H) through 118 (Og). + symbol: Conventional case-sensitive element symbol. + name: English element name. + + Examples: + >>> get_element("cl") + Element(z=17, symbol='Cl', name='Chlorine') + """ z: int symbol: str @@ -49,7 +60,28 @@ def canonicalize_element_symbol(token: str | None) -> str | None: The function accepts strings such as ``"cl"``, ``" Cl "`` or ``"Cl12"`` and returns ``"Cl"`` when a leading element-like token can be - identified. Missing-value markers and non-element strings return ``None``. + identified. It normalizes spelling but does not validate that the result is + a known element. + + Args: + token: Free-form token, or `None`. Empty strings and the missing-value + markers ``"?"`` and ``"."`` are treated as missing. + + Returns: + A conventionally capitalized leading element-like token, or `None` if + no such token is present. + + Examples: + >>> canonicalize_element_symbol(" Cl12 ") + 'Cl' + >>> canonicalize_element_symbol("?") is None + True + + Notes: + Call + [is_valid_element_symbol][atomref.elements.is_valid_element_symbol] or + [get_element][atomref.elements.get_element] when membership in the + packaged periodic table must also be checked. """ raw = _normalize_element_token(token) @@ -69,7 +101,9 @@ def _load_elements_by_symbol() -> dict[str, Element]: """Load the packaged periodic table into a symbol-keyed mapping.""" table_path = resources.files("atomref.data").joinpath("periodic_table.csv") - with table_path.open("r", encoding="utf-8", newline="") as handle: + with io.TextIOWrapper( + table_path.open("rb"), encoding="utf-8", newline="" + ) as handle: reader = csv.DictReader(handle) out: dict[str, Element] = {} for row in reader: @@ -88,7 +122,21 @@ def _elements_in_z_order() -> tuple[Element, ...]: def is_valid_element_symbol(symbol: str | None) -> bool: - """Return ``True`` if ``symbol`` is a known packaged element symbol.""" + """Check whether a canonical symbol is present in the periodic table. + + Args: + symbol: Case-sensitive canonical symbol, or `None`. + + Returns: + `True` only for an exact packaged symbol. This function does not trim or + canonicalize its argument. + + Examples: + >>> is_valid_element_symbol("Cl") + True + >>> is_valid_element_symbol("cl") + False + """ if symbol is None: return False @@ -96,7 +144,23 @@ def is_valid_element_symbol(symbol: str | None) -> bool: def get_element(symbol: str | None) -> Element | None: - """Look up packaged element identity from a symbol-like token.""" + """Look up packaged element identity from a symbol-like token. + + Args: + symbol: Free-form symbol token accepted by + [canonicalize_element_symbol][atomref.elements.canonicalize_element_symbol], + or `None`. + + Returns: + The matching immutable [Element][atomref.elements.Element], or `None` if + the token is missing or does not identify a packaged element. + + Examples: + >>> get_element(" Cl12 ").z + 17 + >>> get_element("not-an-element") is None + True + """ sym = canonicalize_element_symbol(symbol) if sym is None: @@ -105,6 +169,15 @@ def get_element(symbol: str | None) -> Element | None: def iter_elements() -> tuple[Element, ...]: - """Return all packaged elements in increasing atomic-number order.""" + """Return all packaged elements in increasing atomic-number order. + + Returns: + An immutable tuple containing H through Og, ordered by atomic number. + + Examples: + >>> elements = iter_elements() + >>> elements[0].symbol, elements[-1].symbol + ('H', 'Og') + """ return _elements_in_z_order() diff --git a/src/atomref/errors.py b/src/atomref/errors.py index d31660a..07c1402 100644 --- a/src/atomref/errors.py +++ b/src/atomref/errors.py @@ -1,17 +1,45 @@ -"""Package-local exceptions used across :mod:`atomref`.""" +"""Catchable exceptions raised by `atomref` submodules.""" class AtomrefError(Exception): - """Base class for package-defined errors.""" + """Base class for package-defined operational errors. + + Notes: + The exception classes are documented from `atomref.errors` but are not + re-exported from the top-level `atomref` namespace. + """ class DatasetError(AtomrefError): - """Raised when packaged data or registry metadata are invalid.""" + """Report an unavailable, unknown, or malformed dataset. + + Examples: + Catch this exception when a user-selected dataset identifier may be + unavailable: + + >>> from atomref.errors import DatasetError + >>> try: + ... raise DatasetError("unknown dataset") + ... except DatasetError: + ... pass + """ class MissingValueError(AtomrefError): - """Raised when a required reference value is unavailable.""" + """Report that an operation requires an unavailable reference value. + + Notes: + Ordinary lookup functions generally represent missing scientific data + with `None` or a missing [LookupResult][atomref.LookupResult] rather than + raising this exception. + """ class PolicyError(AtomrefError): - """Raised for invalid policy configuration or transfer resolution.""" + """Report invalid policy configuration or transfer resolution. + + Examples: + This includes incompatible radii kinds, invalid transfer controls, and + cyclic nested policies. Callers that accept user-authored policies can + catch `PolicyError` to distinguish those failures from missing values. + """ diff --git a/src/atomref/policy.py b/src/atomref/policy.py index 79cc9f3..b019c56 100644 --- a/src/atomref/policy.py +++ b/src/atomref/policy.py @@ -8,7 +8,14 @@ from functools import lru_cache import math from types import MappingProxyType -from typing import Generic, Literal, TypeVar +from typing import ( + Generic, + Literal, + SupportsFloat, + SupportsIndex, + TypeVar, + cast, +) from .elements import ( canonicalize_element_symbol, @@ -17,12 +24,11 @@ ) from .errors import PolicyError from .registry import ( - DatasetLike, DatasetRef, ElementScalarSet, + ScalarDatasetLike, _is_placeholder_value, - get_builtin_set, - resolve_dataset_like, + resolve_scalar_dataset_like, ) from .transfer import ( LinearFit, @@ -30,10 +36,13 @@ SubstitutionTransfer, SupportsValuePolicy, TransferModel, + TransferValueSource, ) K = TypeVar("K") +_FloatLike = str | bytes | bytearray | memoryview | SupportsFloat | SupportsIndex + LookupSource = Literal[ "override", "base", @@ -42,6 +51,7 @@ "fallback", "missing", ] +"""Provenance labels emitted by the scalar policy resolver.""" PolicyToken = tuple[str, int] _ACTIVE_POLICY_TOKENS: contextvars.ContextVar[tuple[PolicyToken, ...]] = ( @@ -53,10 +63,29 @@ class LookupResult: """Result of resolving one value through a policy. - ``value`` carries the final scalar value when one could be produced, while - ``source`` and the remaining metadata explain how that value was obtained. - ``transfer_depth`` counts how many transfer steps were involved in producing - the returned value. Direct base and override values therefore have depth 0. + Attributes: + value: Resolved scalar in the target policy's units, or `None` when no + rule supplied a value. + source: Rule that supplied `value`, or ``"missing"``. + target: Dataset identity the policy is resolving. + resolved_from: Ordered source datasets contributing to a transferred + value. + is_placeholder: Whether the returned scalar equals its source dataset's + declared placeholder value. + fit: Linear-fit diagnostics when `source` is ``"transfer_linear"``. + notes: Human-readable resolution and rejection diagnostics. + transfer_depth: Number of transfer steps involved. Base, override, + fallback, and missing results have depth 0. + + Examples: + >>> import atomref as ar + >>> result = ar.lookup_covalent_radius("C") + >>> result.value, result.source + (0.76, 'base') + + Notes: + `is_placeholder` describes the returned numeric value, not whether a + transfer occurred. """ value: float | None @@ -69,7 +98,14 @@ class LookupResult: transfer_depth: int = 0 def __float__(self) -> float: - """Coerce the resolved value to ``float`` or raise if it is missing.""" + """Coerce a present resolved value to `float`. + + Returns: + The resolved scalar value. + + Raises: + TypeError: If this result represents a missing value. + """ if self.value is None: raise TypeError("reference value is missing") @@ -80,13 +116,40 @@ def __float__(self) -> float: class ValuePolicy(Generic[K]): """Ordered rule set for resolving element-domain scalar values. - The current runtime resolves only element-domain policies even though the - metadata layer already records a more general ``domain`` concept. During - construction, element-domain override keys are normalized to canonical - element symbols and validated as finite floats. + Attributes: + base: Packaged [DatasetRef][atomref.registry.DatasetRef] or custom + [ElementScalarSet][atomref.registry.ElementScalarSet] that owns the + target quantity and units. + transfers: Ordered substitution or linear-transfer rules. Defaults to + no transfers. + overrides: Explicit key-to-value replacements checked before the base + set. Element keys are canonicalized and values must be finite. + fallback: Final finite scalar used after all transfers fail, or `None`. + Defaults to `None`. + blocked: Element symbols that must resolve as missing. Blocked keys take + precedence over overrides and all other rules. + + Raises: + DatasetError: If the base reference is unknown or has a radial payload. + PolicyError: If fallback, override, or blocked configuration is invalid. + + Examples: + >>> import atomref as ar + >>> policy = ar.ValuePolicy( + ... base=ar.DatasetRef("covalent_radius", "cordero2008"), + ... overrides={"C": 0.77}, + ... ) + >>> ar.get_value("C", policy=policy) + 0.77 + + Notes: + Resolution order is blocked, override, base, transfers, fallback, then + missing. The current resolver supports element-domain scalar data only. + Values are not converted between units; every source in one policy must + be dimensionally compatible with the base set. """ - base: DatasetLike + base: ScalarDatasetLike transfers: tuple[TransferModel, ...] = () overrides: Mapping[K, float] = field(default_factory=dict) fallback: float | None = None @@ -102,7 +165,7 @@ def __post_init__(self) -> None: _coerce_policy_float(self.fallback, what="policy fallback"), ) - base_set = resolve_dataset_like(self.base) + base_set = resolve_scalar_dataset_like(self.base) if base_set.info.domain != "element": return @@ -123,27 +186,31 @@ def __post_init__(self) -> None: normalized_overrides: dict[str, float] = {} seen_original_keys: dict[str, str] = {} - for key, value in self.overrides.items(): - if not isinstance(key, str): + for override_key, value in self.overrides.items(): + if not isinstance(override_key, str): raise PolicyError( "element-domain policy overrides must be keyed by element " "symbols" ) - sym = _normalize_element_symbol(key) + sym = _normalize_element_symbol(override_key) if sym is None: - raise PolicyError(f"invalid override element symbol: {key!r}") + raise PolicyError( + f"invalid override element symbol: {override_key!r}" + ) if sym in seen_blocked: - raise PolicyError(f"override key {key!r} is blocked in this policy") + raise PolicyError( + f"override key {override_key!r} is blocked in this policy" + ) previous = seen_original_keys.get(sym) - if previous is not None and previous != key: + if previous is not None and previous != override_key: raise PolicyError( - f"override keys {previous!r} and {key!r} both normalize to " - f"{sym!r}" + f"override keys {previous!r} and {override_key!r} both " + f"normalize to {sym!r}" ) - seen_original_keys[sym] = key + seen_original_keys[sym] = override_key normalized_overrides[sym] = _coerce_policy_float( value, - what=f"override value for {key!r}", + what=f"override value for {override_key!r}", ) object.__setattr__( @@ -183,7 +250,7 @@ def _coerce_policy_float(value: object, *, what: str) -> float: """Return a finite float for policy configuration values.""" try: - out = float(value) + out = float(cast(_FloatLike, value)) except (TypeError, ValueError) as exc: raise PolicyError(f"{what} must be a finite float") from exc if not math.isfinite(out): @@ -207,22 +274,23 @@ def _normalize_element_symbol(symbol: str | None) -> str | None: return cand -def _resolve_target_ref(policy: ValuePolicy[object]) -> DatasetRef: +def _resolve_target_ref(policy: ValuePolicy[K]) -> DatasetRef: """Return the target dataset reference implied by a policy base.""" - return resolve_dataset_like(policy.base).ref + return resolve_scalar_dataset_like(policy.base).ref def _policy_resolution_tokens( - policy: ValuePolicy[object], + policy: ValuePolicy[K], *, owner: object | None = None, ) -> tuple[PolicyToken, ...]: """Return all tokens that should be considered active for one resolution. - We always track the concrete :class:`ValuePolicy` object identity. When a - wrapper object such as :class:`atomref.radii.RadiiPolicy` or - :class:`atomref.xh.XHPolicy` is the logical source, we also track the + We always track the concrete [ValuePolicy][atomref.policy.ValuePolicy] + object identity. When a + wrapper object such as [RadiiPolicy][atomref.RadiiPolicy] or + [XHPolicy][atomref.XHPolicy] is the logical source, we also track the wrapper identity so recursion through freshly materialized generic policies is still detected. """ @@ -260,31 +328,32 @@ def _coerce_nested_policy( def _materialize_transfer_source( - source: DatasetLike | SupportsValuePolicy | ValuePolicy[str], + source: ScalarDatasetLike | SupportsValuePolicy | ValuePolicy[str], ) -> _ResolvedElementSource: """Materialize any element-domain transfer source into dense by-Z arrays.""" nested_policy, nested_owner = _coerce_nested_policy(source) if nested_policy is None: - dataset = resolve_dataset_like(source) - placeholders = tuple( + dataset_source = cast(ScalarDatasetLike, source) + dataset = resolve_scalar_dataset_like(dataset_source) + dataset_placeholders = tuple( False if value is None else _is_placeholder_value(dataset.info, float(value)) for value in dataset.values_by_z ) - lookup_sources = tuple( + dataset_lookup_sources: tuple[LookupSource | None, ...] = tuple( "base" if value is not None else None for value in dataset.values_by_z ) - transfer_depths = tuple( + dataset_transfer_depths = tuple( 0 if value is not None else None for value in dataset.values_by_z ) return _ResolvedElementSource( ref=dataset.ref, values_by_z=dataset.values_by_z, - placeholder_by_z=placeholders, - lookup_source_by_z=lookup_sources, - transfer_depth_by_z=transfer_depths, + placeholder_by_z=dataset_placeholders, + lookup_source_by_z=dataset_lookup_sources, + transfer_depth_by_z=dataset_transfer_depths, via_policy=False, ) @@ -317,13 +386,14 @@ def _materialize_transfer_source( def _lookup_transfer_source_value( symbol: str, - source: DatasetLike | SupportsValuePolicy | ValuePolicy[str], + source: ScalarDatasetLike | SupportsValuePolicy | ValuePolicy[str], ) -> tuple[_TransferSourceValue | None, str | None]: """Resolve one element value from a transfer source or nested policy.""" nested_policy, nested_owner = _coerce_nested_policy(source) if nested_policy is None: - source_set = resolve_dataset_like(source) + dataset_source = cast(ScalarDatasetLike, source) + source_set = resolve_scalar_dataset_like(dataset_source) value = source_set.get(symbol) if value is None: return None, f"no value in {source_set.ref.set_id}" @@ -374,7 +444,7 @@ def _transfer_source_is_allowed( lookup_source: LookupSource | None, transfer_depth: int | None, *, - allowed_sources: tuple[str, ...], + allowed_sources: tuple[TransferValueSource, ...], max_depth: int, ) -> bool: """Return whether a nested predictor value may participate downstream.""" @@ -389,7 +459,7 @@ def _explain_rejected_transfer_source( source_role: str, lookup_source: LookupSource | None, transfer_depth: int | None, - allowed_sources: tuple[str, ...], + allowed_sources: tuple[TransferValueSource, ...], max_depth: int, ) -> str: """Return a human-readable explanation for a rejected nested source.""" @@ -414,7 +484,7 @@ def _fit_linear_transfer( *, min_points: int, exclude_placeholders: bool, - fit_sources: tuple[str, ...], + fit_sources: tuple[TransferValueSource, ...], fit_max_depth: int, ) -> LinearFit: """Fit a one-predictor linear transfer model between two sources.""" @@ -487,13 +557,13 @@ def _fit_linear_transfer_cached( predictor_ref: DatasetRef, min_points: int, exclude_placeholders: bool, - fit_sources: tuple[str, ...], + fit_sources: tuple[TransferValueSource, ...], fit_max_depth: int, ) -> LinearFit: """Cache fits between two packaged datasets for repeated reuse.""" return _fit_linear_transfer( - get_builtin_set(base_ref), + resolve_scalar_dataset_like(base_ref), _materialize_transfer_source(predictor_ref), min_points=min_points, exclude_placeholders=exclude_placeholders, @@ -502,7 +572,9 @@ def _fit_linear_transfer_cached( ) -def _fit_transfer_model(base: DatasetLike, transfer: TransferModel) -> LinearFit | None: +def _fit_transfer_model( + base: ScalarDatasetLike, transfer: TransferModel +) -> LinearFit | None: """Return the fit object for a transfer model when it needs one.""" if not isinstance(transfer, LinearTransfer): @@ -523,7 +595,7 @@ def _fit_transfer_model(base: DatasetLike, transfer: TransferModel) -> LinearFit transfer.fit_max_depth, ) return _fit_linear_transfer( - resolve_dataset_like(base), + resolve_scalar_dataset_like(base), _materialize_transfer_source(predictor), min_points=transfer.min_points, exclude_placeholders=transfer.exclude_placeholders, @@ -572,7 +644,7 @@ def _apply_substitution_transfer( def _apply_linear_transfer( symbol: str, *, - base: DatasetLike, + base: ScalarDatasetLike, target: DatasetRef, transfer: LinearTransfer, ) -> tuple[LookupResult | None, str | None]: @@ -661,7 +733,7 @@ def _resolve_value( stack_token = _ACTIVE_POLICY_TOKENS.set(active_tokens + resolution_tokens) try: target = _resolve_target_ref(policy) - base_set = resolve_dataset_like(policy.base) + base_set = resolve_scalar_dataset_like(policy.base) if base_set.info.domain != "element": raise PolicyError( "the resolver currently supports only element-domain datasets" @@ -669,12 +741,14 @@ def _resolve_value( sym = _normalize_element_symbol(symbol) if sym is None: - note = "unknown element" if symbol is not None else "missing element symbol" + missing_note = ( + "unknown element" if symbol is not None else "missing element symbol" + ) return LookupResult( value=None, source="missing", target=target, - notes=(note,), + notes=(missing_note,), ) if sym in policy.blocked: @@ -716,13 +790,13 @@ def _resolve_value( transfer_notes: list[str] = ["missing in base set"] for transfer in policy.transfers: if isinstance(transfer, SubstitutionTransfer): - result, note = _apply_substitution_transfer( + result, transfer_note = _apply_substitution_transfer( sym, target=target, transfer=transfer, ) elif isinstance(transfer, LinearTransfer): - result, note = _apply_linear_transfer( + result, transfer_note = _apply_linear_transfer( sym, base=policy.base, target=target, @@ -733,8 +807,8 @@ def _resolve_value( if result is not None: return result - if note: - transfer_notes.append(note) + if transfer_note: + transfer_notes.append(transfer_note) if policy.fallback is not None: return LookupResult( @@ -781,14 +855,50 @@ def _get_value_from_policy_source( def lookup_value(symbol: str | None, *, policy: ValuePolicy[str]) -> LookupResult: """Public entry point for generic element-domain scalar lookup. - This is the same resolver used internally by the radii convenience layer. - In the current implementation the runtime supports only element-domain policies. + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: Element-domain scalar policy to apply. + + Returns: + A [LookupResult][atomref.policy.LookupResult] containing the value or an + explicit missing result, together with provenance and transfer + diagnostics. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If transfer configuration is invalid, fitting cannot meet + its contract, or nested policies form a cycle. + + Examples: + >>> import atomref as ar + >>> policy = ar.DEFAULT_COVALENT_POLICY.as_value_policy() + >>> result = ar.lookup_value("O", policy=policy) + >>> result.value + 0.66 + + Notes: + Invalid or uncovered elements normally produce `source="missing"` + rather than raising. This is the same resolver used by the radii and + X-H convenience layers. """ return _lookup_value_with_owner(symbol, policy=policy, owner=None) def get_value(symbol: str | None, *, policy: ValuePolicy[str]) -> float | None: - """Return only the resolved scalar value for an element-domain policy.""" + """Return only the scalar selected by an element-domain policy. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: Element-domain scalar policy to apply. + + Returns: + The selected finite scalar in the policy's target units, or `None` when + resolution is missing. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If transfer configuration or nested resolution is invalid. + """ return lookup_value(symbol, policy=policy).value diff --git a/src/atomref/proatoms.py b/src/atomref/proatoms.py new file mode 100644 index 0000000..b6d50cd --- /dev/null +++ b/src/atomref/proatoms.py @@ -0,0 +1,2026 @@ +"""Neutral spherical proatomic-density profiles and scalar evaluation.""" + +from __future__ import annotations + +from bisect import bisect_left, bisect_right +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from functools import lru_cache +import math +from typing import Literal, SupportsFloat, SupportsIndex, cast + +from .elements import Element, get_element, iter_elements +from .errors import DatasetError +from .registry import ( + DatasetInfo, + DatasetRef, + ElementRadialSet, + _normalize_element_domain_symbol, + get_builtin_set, + get_dataset_info, + list_dataset_ids, + list_dataset_infos, +) + + +DEFAULT_PROATOMIC_DENSITY_SET = "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2" +"""Identifier of the default packaged neutral proatomic-density set.""" + +BOHR_TO_ANGSTROM = 0.529177210903 +"""Bohr radius in angstrom, used for public coordinate conversion.""" + +_QUANTITY = "proatomic_density" +_NATIVE_RADIUS_UNIT = "bohr" +_NATIVE_DENSITY_UNIT = "electron/bohr^3" +_ANGSTROM_DENSITY_UNIT = "electron/angstrom^3" +_INTERPOLATION_CONTRACT = "loglog_positive_bracketed_v1" + +PROATOMIC_TAIL_CUTOFF = 1.0e-4 +"""Fixed per-atom tail cutoff in electron/bohr^3 for pairwise estimates.""" + +IAS_MINIMUM_RESOLUTION_BOHR = 0.01 +"""Declared spatial resolution of the practical minimum search, in bohr.""" + +_PAIRWISE_CONTRACT = "neutral_proatom_pairwise_cutoff_1e-4_resolution_0.01_v1" +_MINIMUM_INITIAL_SPACING_BOHR = 0.02 +_MINIMUM_CONFIRM_SPACING_BOHR = 0.01 +_MINIMUM_FALLBACK_SPACING_BOHR = 0.005 +_COMPETITIVE_RELATIVE_DEPTH = 1.0e-4 +_EQUALITY_BRACKET_TOLERANCE_BOHR = 1.0e-10 +_CUTOFF_REPRODUCTION_REL_TOL = 5.0e-14 +_FLOAT_COMPARISON_REL_TOL = 64.0 * 2.220446049250313e-16 + +_FloatLike = str | bytes | bytearray | memoryview | SupportsFloat | SupportsIndex + +_IASRequestedMode = Literal["boundary", "minimum"] +_IASMethod = Literal[ + "homonuclear_midpoint", + "equal_proatom_density", + "cutoff_gap_midpoint", + "promolecular_density_minimum", + "none", +] +_IASStatus = Literal[ + "ok", + "low_density_gap", + "one_atom_dominates", + "no_resolved_interior_minimum", + "boundary_dominated", + "ambiguous_competing_minima", + "search_unstable", +] +_CutoffRegime = Literal["overlap", "contact", "gap"] +_NativeDominantSide = Literal["a", "b"] +_DominantAtomRole = Literal["atom_a", "atom_b"] + + +@dataclass(frozen=True, slots=True) +class IASPositionResult: + """Immutable result of one pairwise neutral-proatom estimate. + + Attributes: + atom_a: Canonical symbol of the first requested atom. + atom_b: Canonical symbol of the second requested atom. + distance: Requested positive pair distance, no greater than 20 bohr + after conversion. + distance_unit: Unit used by `distance`, coordinates, cutoff radii, + contour separation, and search resolution: ``"angstrom"`` or + ``"bohr"``. + density_unit: Unit used by all density-valued fields: + ``"electron/bohr^3"`` or ``"electron/angstrom^3"``. + requested_mode: Requested ``"boundary"`` or ``"minimum"`` policy. + method: Actual construction: symmetry midpoint, equal-proatom divider, + cutoff-gap midpoint, promolecular minimum, or ``"none"``. + status: Scientific and numerical outcome. Callers should inspect this + together with `method` and `position_from_a`. + position_from_a: Primary coordinate measured from atom A toward atom B + in `distance_unit`, or `None` for a typed non-result. + position_from_b: Complementary coordinate measured from atom B in + `distance_unit`, or `None`. + fraction_from_a: `position_from_a / distance`, normally in [0, 1], or + `None` when no coordinate is returned. + rho_a: Atom A component density at the primary coordinate, or `None`. + rho_b: Atom B component density at the primary coordinate, or `None`. + rho_sum: Sum of the two components at the primary coordinate, or `None`. + cutoff_density: Fixed per-atom tail cutoff converted to `density_unit`. + cutoff_radius_a: Atom A radius at the fixed cutoff in `distance_unit`. + cutoff_radius_b: Atom B radius at the fixed cutoff in `distance_unit`. + contour_separation: Signed `distance - cutoff_radius_a - + cutoff_radius_b` in `distance_unit`; positive values indicate a gap. + cutoff_regime: ``"overlap"``, ``"contact"``, or ``"gap"`` according to + the signed contour separation. + dominant_atom: Canonical symbol of the atom that dominates an unlike + interval, or `None`. + dominant_atom_role: Whether `dominant_atom` is ``"atom_a"`` or + ``"atom_b"``, or `None`. + alternative_position_from_a: Competitive alternative-minimum coordinate + from atom A in `distance_unit`, or `None`. + alternative_position_from_b: Complementary alternative coordinate from + atom B in `distance_unit`, or `None`. + alternative_rho_sum: Summed density at the alternative, or `None`. + relative_depth_gap: Nonnegative dimensionless relative density gap + between alternative and selected minima, or `None`. + ambiguous: Whether a competitive resolved alternative meets the fixed + relative-depth criterion. + search_resolution: Finest minimum-search spacing in `distance_unit`, or + `None` when minimum search is not applicable. + search_converged: Whether required minimum-search passes agreed, or + `None` outside applicable minimum searches. + search_passes: Number of practical minimum-search passes, or `None` when + not applicable. + dataset_id: Canonical packaged proatomic-density dataset ID. + interpolation_contract: Stable radial interpolation identifier. + pairwise_contract: Stable cutoff and numerical-search identifier. + coordinate_orientation: Explicit orientation label. The default and + current value is ``"from_atom_a_toward_atom_b"``. + + Notes: + A valid but scientifically non-applicable request uses ``method="none"`` + and leaves coordinate fields as `None`. Reversing the atoms maps each + coordinate `x` to `R - x`, swaps A/B fields, and relabels dominance. + + ``"boundary_dominated"`` takes status precedence over + ``"search_unstable"``, which takes precedence over + ``"ambiguous_competing_minima"``. Separate boolean diagnostics preserve + the underlying conditions. Neither mode is an exact molecular QTAIM + surface or critical-point calculation. + + At an odd subnormal homonuclear distance, the mathematical midpoint may + not be representable in binary64. The exposed coordinate then uses the + ordinary `R / 2` result and preserves the complementary distance. + """ + + atom_a: str + atom_b: str + distance: float + distance_unit: str + density_unit: str + requested_mode: _IASRequestedMode + method: _IASMethod + status: _IASStatus + position_from_a: float | None + position_from_b: float | None + fraction_from_a: float | None + rho_a: float | None + rho_b: float | None + rho_sum: float | None + cutoff_density: float + cutoff_radius_a: float + cutoff_radius_b: float + contour_separation: float + cutoff_regime: _CutoffRegime + dominant_atom: str | None + dominant_atom_role: _DominantAtomRole | None + alternative_position_from_a: float | None + alternative_position_from_b: float | None + alternative_rho_sum: float | None + relative_depth_gap: float | None + ambiguous: bool + search_resolution: float | None + search_converged: bool | None + search_passes: int | None + dataset_id: str + interpolation_contract: str + pairwise_contract: str + coordinate_orientation: str = "from_atom_a_toward_atom_b" + + +ProatomicDensitySet = ElementRadialSet +"""Loaded immutable element-indexed proatomic-density dataset.""" + + +def _require_storage(info: DatasetInfo) -> Mapping[str, object]: + """Return density storage metadata or raise a clear dataset error.""" + + storage = info.storage + if not isinstance(storage, Mapping): + raise DatasetError(f"missing storage metadata for dataset: {info.ref!r}") + if storage.get("native_coordinate_unit") != _NATIVE_RADIUS_UNIT: + raise DatasetError( + f"unsupported native coordinate unit for dataset: {info.ref!r}" + ) + if storage.get("native_density_unit") != _NATIVE_DENSITY_UNIT: + raise DatasetError(f"unsupported native density unit for dataset: {info.ref!r}") + if storage.get("interpolation_contract") != _INTERPOLATION_CONTRACT: + raise DatasetError( + f"unsupported interpolation contract for dataset: {info.ref!r}" + ) + return storage + + +def _coerce_public_max_radius(storage: Mapping[str, object], ref: DatasetRef) -> float: + """Return the finite positive public radius limit declared by metadata.""" + + try: + public_max = float( + cast(_FloatLike, storage["public_max_radius_bohr"]) + ) + except (KeyError, TypeError, ValueError) as exc: + raise DatasetError(f"invalid public radius limit for dataset: {ref!r}") from exc + if not math.isfinite(public_max) or public_max <= 0.0: + raise DatasetError(f"invalid public radius limit for dataset: {ref!r}") + return public_max + + +def _dataset_public_max_radius_bohr(dataset: ElementRadialSet) -> float: + """Validate and return one dataset's declared public radius limit.""" + + storage = _require_storage(dataset.info) + public_max = _coerce_public_max_radius(storage, dataset.ref) + if not dataset.radii or public_max > dataset.radii[-1]: + raise DatasetError( + f"radial grid does not bracket the public limit for {dataset.ref!r}" + ) + return public_max + + +@dataclass(frozen=True, slots=True) +class ProatomicDensityProfile: + """Immutable view of one neutral spherical proatomic-density profile. + + Attributes: + dataset: Immutable radial dataset owning the shared grid and profiles. + atomic_number: Selected integer atomic number. The packaged neutral set + supports 1 (H) through 103 (Lr). + symbol: Canonical element symbol initialized from `atomic_number`. + ref: Canonical registry reference for `dataset`. + info: Curated metadata and provenance for `dataset`. + radii: Shared immutable source grid in bohr, including the endpoint + bracket above the public domain. + densities: Immutable sampled values in electron/bohr^3. + interpolation_contract: Stable identifier for evaluation behavior. + public_max_radius_bohr: Inclusive public radius limit in bohr; 20 for + the packaged neutral H-Lr set. + + Raises: + DatasetError: If the atomic number is invalid, the profile is absent, + or radial data or metadata violate the interpolation contract. + + Examples: + >>> profile = get_proatomic_density_profile("O") + >>> profile is not None + True + >>> profile(1.5, radius_unit="bohr") > 0.0 + True + + Notes: + Evaluation is scalar and uses positive-region log-log interpolation. + At the origin and below the first finite grid point, the first stored + value is returned as a finite-grid convention, not an exact nuclear + density. + """ + + dataset: ElementRadialSet = field(repr=False) + atomic_number: int + symbol: str = field(init=False) + _densities: tuple[float, ...] = field(init=False, repr=False) + _log_radii: tuple[float, ...] = field(init=False, repr=False) + _log_densities: tuple[float, ...] = field(init=False, repr=False) + _public_max_radius_bohr: float = field(init=False, repr=False) + + def __post_init__(self) -> None: + """Validate and precompute the positive log-log interpolation data.""" + + if self.dataset.ref != self.dataset.info.ref: + raise DatasetError( + "radial dataset reference does not match its metadata reference: " + f"{self.dataset.ref!r} != {self.dataset.info.ref!r}" + ) + element = _get_element_by_atomic_number(self.atomic_number) + if element is None: + raise DatasetError( + f"invalid atomic number for proatomic-density profile: " + f"{self.atomic_number!r}" + ) + object.__setattr__(self, "symbol", element.symbol) + + radii = self.dataset.radii + densities = self.dataset.get(self.atomic_number) + if densities is None: + raise DatasetError( + f"profile Z={self.atomic_number} is absent from {self.dataset.ref!r}" + ) + if len(radii) != len(densities) or not radii: + raise DatasetError( + f"radial grid/profile length mismatch for {self.dataset.ref!r}, " + f"Z={self.atomic_number}" + ) + if any(not math.isfinite(radius) or radius <= 0.0 for radius in radii): + raise DatasetError( + f"radial grid must be finite and positive for {self.dataset.ref!r}" + ) + if any(right <= left for left, right in zip(radii, radii[1:])): + raise DatasetError( + f"radial grid must strictly increase for {self.dataset.ref!r}" + ) + if any(not math.isfinite(value) or value <= 0.0 for value in densities): + raise DatasetError( + f"profile values must be finite and positive for " + f"{self.dataset.ref!r}, Z={self.atomic_number}" + ) + + public_max = _dataset_public_max_radius_bohr(self.dataset) + + object.__setattr__(self, "_densities", densities) + object.__setattr__(self, "_log_radii", tuple(math.log(r) for r in radii)) + object.__setattr__( + self, + "_log_densities", + tuple(math.log(value) for value in densities), + ) + object.__setattr__(self, "_public_max_radius_bohr", public_max) + + @property + def ref(self) -> DatasetRef: + """Return the canonical registry reference. + + Returns: + Source [DatasetRef][atomref.registry.DatasetRef]. + """ + + return self.dataset.ref + + @property + def info(self) -> DatasetInfo: + """Return curated source metadata and provenance. + + Returns: + Source [DatasetInfo][atomref.registry.DatasetInfo]. + """ + + return self.dataset.info + + @property + def radii(self) -> tuple[float, ...]: + """Return the shared immutable source grid. + + Returns: + Positive radii in bohr, including the endpoint bracket above the + 20-bohr public domain. + """ + + return self.dataset.radii + + @property + def densities(self) -> tuple[float, ...]: + """Return immutable stored density values. + + Returns: + Positive sampled values in electron/bohr^3, aligned with `radii`. + """ + + return self._densities + + @property + def interpolation_contract(self) -> str: + """Return the interpolation-contract identifier. + + Returns: + ``"loglog_positive_bracketed_v1"``. + """ + + return _INTERPOLATION_CONTRACT + + @property + def public_max_radius_bohr(self) -> float: + """Return the inclusive public radius limit. + + Returns: + Largest supported coordinate in bohr; 20 for the packaged neutral + dataset. + """ + + return self._public_max_radius_bohr + + def evaluate( + self, + radius: float, + *, + radius_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", + ) -> float: + """Evaluate the density at one radius. + + Args: + radius: Finite scalar coordinate in `radius_unit`. It must map to + the inclusive interval 0 through 20 bohr for the packaged set. + radius_unit: ``"angstrom"`` (default) or ``"bohr"``. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"``. This choice is independent of the + coordinate unit. + + Returns: + Finite positive interpolated density in `density_unit`. + + Raises: + ValueError: If a unit is unknown or `radius` is negative, + non-finite, nonscalar, or above the public limit. + DatasetError: If the stored radial grid cannot bracket evaluation. + + Examples: + >>> profile = get_proatomic_density_profile("O") + >>> profile is not None + True + >>> profile.evaluate(0.75) > 0.0 + True + """ + + radius_bohr = _radius_to_bohr( + radius, + radius_unit=radius_unit, + public_max_bohr=self._public_max_radius_bohr, + ) + density = self._evaluate_bohr(radius_bohr) + return _density_from_native(density, density_unit=density_unit) + + def __call__( + self, + radius: float, + *, + radius_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", + ) -> float: + """Evaluate the density; equivalent to + [evaluate][atomref.proatoms.ProatomicDensityProfile.evaluate]. + + Args: + radius: Finite coordinate from 0 through the public 20-bohr limit. + radius_unit: ``"angstrom"`` (default) or ``"bohr"``. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"``. + + Returns: + Finite positive interpolated density in `density_unit`. + + Raises: + ValueError: If a unit or radius is outside the public contract. + DatasetError: If the stored radial data cannot support evaluation. + """ + + return self.evaluate( + radius, + radius_unit=radius_unit, + density_unit=density_unit, + ) + + def _evaluate_bohr(self, radius_bohr: float) -> float: + """Evaluate one already validated native-coordinate radius.""" + + radii = self.dataset.radii + if radius_bohr <= radii[0]: + return self._densities[0] + + right = bisect_left(radii, radius_bohr) + if right < len(radii) and radii[right] == radius_bohr: + return self._densities[right] + if right == len(radii): + raise DatasetError( + f"radial grid does not bracket radius {radius_bohr!r} bohr for " + f"{self.dataset.ref!r}" + ) + + left = right - 1 + fraction = ( + (math.log(radius_bohr) - self._log_radii[left]) + / (self._log_radii[right] - self._log_radii[left]) + ) + log_density = self._log_densities[left] + fraction * ( + self._log_densities[right] - self._log_densities[left] + ) + density = math.exp(log_density) + if not math.isfinite(density) or density <= 0.0: + raise DatasetError( + f"density interpolation failed for {self.dataset.ref!r}, " + f"Z={self.atomic_number}" + ) + return density + + +def _radius_to_bohr( + radius: float, + *, + radius_unit: Literal["angstrom", "bohr"], + public_max_bohr: float, +) -> float: + """Validate one public radius and convert it to bohr.""" + + if radius_unit not in {"angstrom", "bohr"}: + raise ValueError(f"unknown radius unit: {radius_unit!r}") + if isinstance(radius, bool): + raise ValueError("radius must be a finite non-negative scalar") + try: + value = float(radius) + except (TypeError, ValueError) as exc: + raise ValueError("radius must be a finite non-negative scalar") from exc + if not math.isfinite(value): + raise ValueError("radius must be finite") + if value < 0.0: + raise ValueError("radius must be non-negative") + + public_max = ( + public_max_bohr + if radius_unit == "bohr" + else public_max_bohr * BOHR_TO_ANGSTROM + ) + if value > public_max: + raise ValueError( + f"radius exceeds the public limit of {public_max_bohr:g} bohr" + ) + if radius_unit == "bohr": + return value + radius_bohr = value / BOHR_TO_ANGSTROM + return min(radius_bohr, public_max_bohr) + + +def _density_from_native( + value: float, + *, + density_unit: str, +) -> float: + """Convert electron/bohr^3 to the selected output density unit.""" + + if density_unit == _NATIVE_DENSITY_UNIT: + return value + if density_unit == _ANGSTROM_DENSITY_UNIT: + return value / BOHR_TO_ANGSTROM**3 + raise ValueError(f"unknown density unit: {density_unit!r}") + + +def list_proatomic_density_sets() -> tuple[str, ...]: + """List packaged proatomic-density dataset identifiers. + + Returns: + Canonical set IDs in curated registry order. + + Raises: + DatasetError: If registry metadata is unavailable or malformed. + """ + + return list_dataset_ids(_QUANTITY) + + +def list_proatomic_density_set_infos() -> tuple[DatasetInfo, ...]: + """Return metadata for all packaged proatomic-density datasets. + + Returns: + Immutable [DatasetInfo][atomref.registry.DatasetInfo] objects in curated + registry order. + + Raises: + DatasetError: If registry metadata is unavailable or malformed. + """ + + return list_dataset_infos(_QUANTITY) + + +def get_proatomic_density_set_info( + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> DatasetInfo: + """Return metadata for one packaged proatomic-density dataset. + + Args: + set_id: Canonical set ID or accepted alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + Curated method, units, provenance, coverage, and storage metadata. + + Raises: + DatasetError: If the set is unknown or metadata is malformed. + """ + + return get_dataset_info(DatasetRef(_QUANTITY, set_id)) + + +def get_proatomic_density_set( + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> ProatomicDensitySet: + """Return one cached immutable packaged proatomic-density dataset. + + Args: + set_id: Canonical set ID or accepted alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + Element-indexed neutral profiles sharing one radial grid. + + Raises: + DatasetError: If the set is unknown, malformed, or has a scalar rather + than radial payload. + """ + + loaded = get_builtin_set(DatasetRef(_QUANTITY, set_id)) + if not isinstance(loaded, ElementRadialSet): + raise DatasetError( + f"dataset {loaded.ref!r} has a scalar payload; radial dataset required" + ) + return loaded + + +def _resolve_proatomic_density_set( + set_id: str, +) -> tuple[ProatomicDensitySet, float]: + """Resolve and load one selected proatomic-density dataset.""" + + info = get_proatomic_density_set_info(set_id) + dataset = get_proatomic_density_set(info.ref.set_id) + return dataset, _dataset_public_max_radius_bohr(dataset) + + +@lru_cache(maxsize=None) +def _get_element_by_atomic_number_cached( + atomic_number: int, +) -> Element | None: + """Return the periodic-table element for a validated integer Z.""" + + return next( + (candidate for candidate in iter_elements() if candidate.z == atomic_number), + None, + ) + + +def _get_element_by_atomic_number(atomic_number: object) -> Element | None: + """Resolve an integer atomic number while rejecting booleans safely.""" + + if not isinstance(atomic_number, int) or isinstance(atomic_number, bool): + return None + return _get_element_by_atomic_number_cached(atomic_number) + + +def _resolve_density_element(element: str | int | None) -> Element | None: + """Resolve a symbol, isotope alias, or integer atomic number.""" + + if isinstance(element, int): + return _get_element_by_atomic_number(element) + if isinstance(element, str) or element is None: + symbol = _normalize_element_domain_symbol(element) + return get_element(symbol) + return None + + +@lru_cache(maxsize=None) +def _get_profile_cached(ref: DatasetRef, atomic_number: int) -> ProatomicDensityProfile: + """Create one shared immutable profile view from a canonical dataset ref.""" + + dataset = get_builtin_set(ref) + if not isinstance(dataset, ElementRadialSet): + raise DatasetError( + f"dataset {dataset.ref!r} has a scalar payload; radial dataset required" + ) + element = _get_element_by_atomic_number(atomic_number) + if element is None: + raise DatasetError(f"unknown atomic number in radial dataset: {atomic_number}") + return ProatomicDensityProfile( + dataset=dataset, + atomic_number=atomic_number, + ) + + +def get_proatomic_density_profile( + element: str | int | None, + *, + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> ProatomicDensityProfile | None: + """Return a cached neutral profile, or ``None`` for unsupported elements. + + Args: + element: Element symbol, integer atomic number, or `None`. D/T map to + hydrogen's electronic profile; booleans are rejected. + set_id: Canonical set ID or accepted alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + A cached + [ProatomicDensityProfile][atomref.proatoms.ProatomicDensityProfile], or + `None` for an invalid, unsupported, or uncovered element. The packaged + set covers H through Lr. + + Raises: + DatasetError: If the selected dataset is unknown, malformed, or + non-radial. + + Examples: + >>> get_proatomic_density_profile(8).symbol + 'O' + >>> get_proatomic_density_profile("Og") is None + True + + Notes: + No neighboring-element substitution, correlation, ionic selection, or + scalar [ValuePolicy][atomref.ValuePolicy] is applied. + """ + + dataset, _ = _resolve_proatomic_density_set(set_id) + resolved = _resolve_density_element(element) + if resolved is None: + return None + if dataset.get(resolved.z) is None: + return None + return _get_profile_cached(dataset.ref, resolved.z) + + +def get_proatomic_density( + element: str | int | None, + radius: float, + *, + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, + radius_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", +) -> float | None: + """Evaluate one neutral proatomic density, or return ``None`` if absent. + + Args: + element: Element symbol, integer atomic number, or `None`. D/T map to H. + radius: Finite nonnegative scalar coordinate in `radius_unit`, no greater + than 20 bohr after conversion. + set_id: Canonical set ID or alias. Defaults to the packaged neutral set. + radius_unit: ``"angstrom"`` (default) or ``"bohr"``. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"``. + + Returns: + Finite positive scalar density in `density_unit`, or `None` for an + invalid, unsupported, or uncovered element. + + Raises: + ValueError: If a unit is unknown or the radius is negative, non-finite, + nonscalar, or above 20 bohr after conversion. + DatasetError: If the selected dataset is unknown, malformed, or + non-radial. + + Examples: + >>> rho = get_proatomic_density("O", 0.75) + >>> rho is not None and rho > 0.0 + True + + Notes: + Radius and density units are independent. Evaluation is scalar and + dependency-free, with no extrapolation beyond 20 bohr. + """ + + dataset, public_max_radius_bohr = _resolve_proatomic_density_set(set_id) + radius_bohr = _radius_to_bohr( + radius, + radius_unit=radius_unit, + public_max_bohr=public_max_radius_bohr, + ) + _density_from_native(1.0, density_unit=density_unit) + + resolved = _resolve_density_element(element) + if resolved is None or dataset.get(resolved.z) is None: + return None + profile = _get_profile_cached(dataset.ref, resolved.z) + return _density_from_native( + profile._evaluate_bohr(radius_bohr), + density_unit=density_unit, + ) + + +@dataclass(frozen=True, slots=True) +class _PreparedPairwiseProfile: + """Cached native numerical representation used by pairwise estimates.""" + + profile: ProatomicDensityProfile + loglog_slopes: tuple[float, ...] + cutoff_radius_bohr: float + + +@dataclass(frozen=True, slots=True) +class _MinimumCandidate: + """One locally refined minimum candidate in native units.""" + + position_bohr: float + density: float + + +@dataclass(frozen=True, slots=True) +class _MinimumPass: + """Raw refined candidates from one deterministic grid pass.""" + + max_spacing_bohr: float + candidates: tuple[_MinimumCandidate, ...] + + @property + def selected(self) -> _MinimumCandidate | None: + """Return the lowest refined candidate, if any.""" + + return min( + self.candidates, + key=lambda candidate: ( + candidate.density, + candidate.position_bohr, + ), + default=None, + ) + + +@dataclass(frozen=True, slots=True) +class _NativeIASResult: + """Pairwise result before requested orientation and unit conversion.""" + + requested_mode: _IASRequestedMode + method: _IASMethod + status: _IASStatus + position_bohr: float | None + rho_a: float | None + rho_b: float | None + rho_sum: float | None + cutoff_radius_a_bohr: float + cutoff_radius_b_bohr: float + contour_separation_bohr: float + cutoff_regime: _CutoffRegime + dominant_side: _NativeDominantSide | None = None + alternative_position_bohr: float | None = None + alternative_rho_sum: float | None = None + relative_depth_gap: float | None = None + ambiguous: bool = False + search_resolution_bohr: float | None = None + search_converged: bool | None = None + search_passes: int | None = None + + +def _prepare_pairwise_profile( + profile: ProatomicDensityProfile, +) -> _PreparedPairwiseProfile: + """Validate and prepare one profile for the fixed pairwise cutoff. + + The inverse cutoff coordinate is obtained analytically from the one + bracketing log-log segment. ``_CUTOFF_REPRODUCTION_REL_TOL`` is a + conservative binary64 envelope; the packaged H-Lr maximum observed error + is substantially smaller. + """ + + densities = profile.densities + if any(right >= left for left, right in zip(densities, densities[1:])): + raise DatasetError( + "pairwise proatomic profiles must be strictly decreasing for " + f"{profile.ref!r}, Z={profile.atomic_number}" + ) + if densities[0] <= PROATOMIC_TAIL_CUTOFF: + raise DatasetError( + "first proatomic density must exceed the pairwise tail cutoff for " + f"{profile.ref!r}, Z={profile.atomic_number}" + ) + + try: + right = next( + index + for index, density in enumerate(densities) + if density <= PROATOMIC_TAIL_CUTOFF + ) + except StopIteration as exc: + raise DatasetError( + "proatomic density does not fall below the pairwise tail cutoff for " + f"{profile.ref!r}, Z={profile.atomic_number}" + ) from exc + if not any( + radius < profile.public_max_radius_bohr + and density < PROATOMIC_TAIL_CUTOFF + for radius, density in zip(profile.radii, densities) + ): + raise DatasetError( + "proatomic density must fall below the pairwise tail cutoff before " + f"the public limit for {profile.ref!r}, Z={profile.atomic_number}" + ) + + slopes = tuple( + (right_log_density - left_log_density) + / (right_log_radius - left_log_radius) + for left_log_radius, right_log_radius, left_log_density, right_log_density + in zip( + profile._log_radii, + profile._log_radii[1:], + profile._log_densities, + profile._log_densities[1:], + ) + ) + + if densities[right] == PROATOMIC_TAIL_CUTOFF: + cutoff_radius = profile.radii[right] + else: + left = right - 1 + cutoff_log_radius = profile._log_radii[left] + ( + math.log(PROATOMIC_TAIL_CUTOFF) - profile._log_densities[left] + ) / slopes[left] + cutoff_radius = math.exp(cutoff_log_radius) + + if not math.isfinite(cutoff_radius) or not ( + 0.0 < cutoff_radius < profile.public_max_radius_bohr + ): + raise DatasetError( + "pairwise tail cutoff is not reached before the public radius limit " + f"for {profile.ref!r}, Z={profile.atomic_number}" + ) + + reproduced_log_density = profile._log_densities[right - 1] + slopes[ + right - 1 + ] * (math.log(cutoff_radius) - profile._log_radii[right - 1]) + reproduced = math.exp(reproduced_log_density) + if not math.isclose( + reproduced, + PROATOMIC_TAIL_CUTOFF, + rel_tol=_CUTOFF_REPRODUCTION_REL_TOL, + abs_tol=0.0, + ): + raise DatasetError( + "analytical pairwise tail-cutoff inversion failed for " + f"{profile.ref!r}, Z={profile.atomic_number}" + ) + + return _PreparedPairwiseProfile( + profile=profile, + loglog_slopes=slopes, + cutoff_radius_bohr=cutoff_radius, + ) + + +@lru_cache(maxsize=None) +def _get_prepared_pairwise_profile_cached( + ref: DatasetRef, + atomic_number: int, +) -> _PreparedPairwiseProfile: + """Return one cached pairwise representation by canonical dataset key.""" + + return _prepare_pairwise_profile(_get_profile_cached(ref, atomic_number)) + + +def _prepared_pairwise_profile( + profile: ProatomicDensityProfile, +) -> _PreparedPairwiseProfile: + """Return the cached pairwise representation for a packaged profile.""" + + return _get_prepared_pairwise_profile_cached( + profile.ref, + profile.atomic_number, + ) + + +def _continuous_log_density_bohr( + prepared: _PreparedPairwiseProfile, + radius_bohr: float, +) -> float: + """Evaluate the continuous accepted log-log representation in log space.""" + + profile = prepared.profile + radii = profile.radii + if radius_bohr <= radii[0]: + return profile._log_densities[0] + + left = min(bisect_right(radii, radius_bohr) - 1, len(radii) - 2) + return profile._log_densities[left] + prepared.loglog_slopes[left] * ( + math.log(radius_bohr) - profile._log_radii[left] + ) + + +def _evaluate_prepared_density_bohr( + prepared: _PreparedPairwiseProfile, + radius_bohr: float, +) -> float: + """Evaluate the exact Stage 3 convention using cached segment slopes.""" + + profile = prepared.profile + radii = profile.radii + if radius_bohr <= radii[0]: + return profile.densities[0] + right = bisect_left(radii, radius_bohr) + if right < len(radii) and radii[right] == radius_bohr: + return profile.densities[right] + left = right - 1 + return math.exp( + profile._log_densities[left] + + prepared.loglog_slopes[left] + * (math.log(radius_bohr) - profile._log_radii[left]) + ) + + +def _component_values( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, + position_bohr: float, +) -> tuple[float, float, float]: + """Evaluate pair components and their sum in native units.""" + + rho_a = _evaluate_prepared_density_bohr(profile_a, position_bohr) + rho_b = _evaluate_prepared_density_bohr( + profile_b, + distance_bohr - position_bohr, + ) + return rho_a, rho_b, rho_a + rho_b + + +def _objective( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, + position_bohr: float, +) -> float: + """Return the native promolecular line-density sum.""" + + return ( + _evaluate_prepared_density_bohr(profile_a, position_bohr) + + _evaluate_prepared_density_bohr( + profile_b, + distance_bohr - position_bohr, + ) + ) + + +def _cutoff_regime( + distance_bohr: float, + cutoff_radius_a_bohr: float, + cutoff_radius_b_bohr: float, +) -> tuple[float, _CutoffRegime]: + """Return signed contour separation and its exact-sign regime.""" + + radius_sum = cutoff_radius_a_bohr + cutoff_radius_b_bohr + separation = distance_bohr - radius_sum + if separation == 0.0: + return separation, "contact" + if separation > 0.0: + return separation, "gap" + return separation, "overlap" + + +def _equal_contribution_position( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, +) -> tuple[float | None, _NativeDominantSide | None]: + """Solve the continuous log-density equality by bracketed bisection.""" + + def difference(position: float) -> float: + return _continuous_log_density_bohr( + profile_a, + position, + ) - _continuous_log_density_bohr( + profile_b, + distance_bohr - position, + ) + + left = 0.0 + right = distance_bohr + left_value = difference(left) + right_value = difference(right) + + if left_value <= 0.0: + return None, "b" + if right_value >= 0.0: + return None, "a" + + while right - left > _EQUALITY_BRACKET_TOLERANCE_BOHR: + midpoint = (left + right) / 2.0 + if midpoint == left or midpoint == right: + break + value = difference(midpoint) + if value == 0.0: + return midpoint, None + if value > 0.0: + left = midpoint + else: + right = midpoint + if math.nextafter(left, right) == right: + break + + return (left + right) / 2.0, None + + +def _native_boundary_estimate( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, +) -> _NativeIASResult: + """Compute boundary mode in canonical orientation and native units.""" + + cutoff_a = profile_a.cutoff_radius_bohr + cutoff_b = profile_b.cutoff_radius_bohr + separation, regime = _cutoff_regime(distance_bohr, cutoff_a, cutoff_b) + + if profile_a.profile.atomic_number == profile_b.profile.atomic_number: + position = distance_bohr / 2.0 + rho_a, rho_b, rho_sum = _component_values( + profile_a, + profile_b, + distance_bohr, + position, + ) + return _NativeIASResult( + requested_mode="boundary", + method="homonuclear_midpoint", + status="low_density_gap" if regime == "gap" else "ok", + position_bohr=position, + rho_a=rho_a, + rho_b=rho_b, + rho_sum=rho_sum, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + ) + + if regime == "gap": + position = (distance_bohr + cutoff_a - cutoff_b) / 2.0 + rho_a, rho_b, rho_sum = _component_values( + profile_a, + profile_b, + distance_bohr, + position, + ) + return _NativeIASResult( + requested_mode="boundary", + method="cutoff_gap_midpoint", + status="low_density_gap", + position_bohr=position, + rho_a=rho_a, + rho_b=rho_b, + rho_sum=rho_sum, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + ) + + equal_position, dominant_side = _equal_contribution_position( + profile_a, + profile_b, + distance_bohr, + ) + if equal_position is None: + return _NativeIASResult( + requested_mode="boundary", + method="none", + status="one_atom_dominates", + position_bohr=None, + rho_a=None, + rho_b=None, + rho_sum=None, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + dominant_side=dominant_side, + ) + + rho_a, rho_b, rho_sum = _component_values( + profile_a, + profile_b, + distance_bohr, + equal_position, + ) + return _NativeIASResult( + requested_mode="boundary", + method="equal_proatom_density", + status="ok", + position_bohr=equal_position, + rho_a=rho_a, + rho_b=rho_b, + rho_sum=rho_sum, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + ) + + +def _bounded_minimum( + function: Callable[[float], float], + left: float, + center: float, + right: float, + center_value: float, +) -> _MinimumCandidate: + """Refine one sample-resolved valley with local golden-section search.""" + + ratio = (math.sqrt(5.0) - 1.0) / 2.0 + x1 = right - ratio * (right - left) + x2 = left + ratio * (right - left) + f1 = function(x1) + f2 = function(x2) + + for _ in range(36): + if math.nextafter(left, right) == right: + break + if f1 <= f2: + right, x2, f2 = x2, x1, f1 + x1 = right - ratio * (right - left) + f1 = function(x1) + else: + left, x1, f1 = x1, x2, f2 + x2 = left + ratio * (right - left) + f2 = function(x2) + + position, density = min( + ((center, center_value), (x1, f1), (x2, f2)), + key=lambda item: (item[1], item[0]), + ) + return _MinimumCandidate(position, density) + + +def _coalesce_minimum_candidates( + candidates: tuple[_MinimumCandidate, ...], +) -> tuple[_MinimumCandidate, ...]: + """Coalesce position-connected candidates at the public resolution.""" + + ordered = sorted(candidates, key=lambda candidate: candidate.position_bohr) + groups: list[list[_MinimumCandidate]] = [] + for candidate in ordered: + if ( + not groups + or candidate.position_bohr - groups[-1][-1].position_bohr + >= IAS_MINIMUM_RESOLUTION_BOHR + ): + groups.append([candidate]) + else: + groups[-1].append(candidate) + representatives = [ + min( + group, + key=lambda candidate: ( + candidate.density, + candidate.position_bohr, + ), + ) + for group in groups + ] + return tuple( + sorted( + representatives, + key=lambda candidate: (candidate.density, candidate.position_bohr), + ) + ) + + +def _minimum_grid_pass( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, + overlap_left: float, + overlap_right: float, + max_spacing_bohr: float, + equal_position_bohr: float | None, +) -> _MinimumPass: + """Run one deterministic practical-resolution valley search pass.""" + + width = overlap_right - overlap_left + segments = max(2, math.ceil(width / max_spacing_bohr)) + coordinates = [ + overlap_left + width * index / segments + for index in range(segments + 1) + ] + coordinates[0] = overlap_left + coordinates[-1] = overlap_right + if segments % 2: + midpoint = (overlap_left + overlap_right) / 2.0 + if midpoint not in coordinates: + coordinates.append(midpoint) + if ( + equal_position_bohr is not None + and overlap_left < equal_position_bohr < overlap_right + and equal_position_bohr not in coordinates + ): + coordinates.append(equal_position_bohr) + coordinates = sorted(set(coordinates)) + + def objective(position: float) -> float: + return _objective( + profile_a, + profile_b, + distance_bohr, + position, + ) + values = [objective(position) for position in coordinates] + candidates: list[_MinimumCandidate] = [] + for index in range(1, len(coordinates) - 1): + if ( + values[index] <= values[index - 1] + and values[index] <= values[index + 1] + ): + candidate = _bounded_minimum( + objective, + coordinates[index - 1], + coordinates[index], + coordinates[index + 1], + values[index], + ) + if overlap_left < candidate.position_bohr < overlap_right: + candidates.append(candidate) + + return _MinimumPass( + max_spacing_bohr=max_spacing_bohr, + candidates=tuple( + sorted( + candidates, + key=lambda candidate: ( + candidate.density, + candidate.position_bohr, + ), + ) + ), + ) + + +def _selections_compatible( + first: _MinimumCandidate | None, + second: _MinimumCandidate | None, +) -> bool: + """Return whether two pass selections agree at the public resolution.""" + + if first is None or second is None: + return first is second + position_agrees = ( + abs(first.position_bohr - second.position_bohr) + <= IAS_MINIMUM_RESOLUTION_BOHR + ) + density_agrees = abs(first.density - second.density) <= ( + _COMPETITIVE_RELATIVE_DEPTH * min(first.density, second.density) + ) + return position_agrees and density_agrees + + +def _combine_confirmed_minimum_candidates( + *passes: _MinimumPass, + candidate_filter: Callable[[_MinimumCandidate], bool] | None = None, +) -> tuple[_MinimumCandidate, ...]: + """Combine candidates from every executed pass before coalescing.""" + + return _coalesce_minimum_candidates( + tuple( + candidate + for minimum_pass in passes + for candidate in minimum_pass.candidates + if candidate_filter is None or candidate_filter(candidate) + ) + ) + + +def _minimum_candidate_is_publicly_strict( + candidate: _MinimumCandidate, + distance_bohr: float, + cutoff_a_bohr: float, + cutoff_b_bohr: float, + extra_presentations: tuple[tuple[float, float], ...] = (), +) -> bool: + """Return whether a minimum stays interior in every public presentation. + + A binary64 subtraction used for pair reversal, or the multiplication used + for angstrom output, can round a native strict-interior coordinate onto an + exposed cutoff endpoint. Reject such a candidate in native space for both + supported units and orientations so this representability decision cannot + depend on how the caller labels the pair or names the distance unit. + """ + + presentations = ( + (distance_bohr, 1.0), + (distance_bohr * BOHR_TO_ANGSTROM, BOHR_TO_ANGSTROM), + *extra_presentations, + ) + for distance, scale in presentations: + cutoff_a = cutoff_a_bohr * scale + cutoff_b = cutoff_b_bohr * scale + position_a = candidate.position_bohr * scale + position_b = distance - position_a + + overlap_left_a = max(0.0, distance - cutoff_b) + overlap_right_a = min(distance, cutoff_a) + overlap_left_b = max(0.0, distance - cutoff_a) + overlap_right_b = min(distance, cutoff_b) + if not ( + overlap_left_a < position_a < overlap_right_a + and overlap_left_b < position_b < overlap_right_b + ): + return False + return True + + +def _less_beyond_roundoff(first: float, second: float) -> bool: + """Return whether ``first`` is meaningfully below ``second`` in binary64.""" + + envelope = _FLOAT_COMPARISON_REL_TOL * max(abs(first), abs(second)) + return first < second - envelope + + +def _native_minimum_estimate( + profile_a: _PreparedPairwiseProfile, + profile_b: _PreparedPairwiseProfile, + distance_bohr: float, + *, + public_presentations: tuple[tuple[float, float], ...] = (), +) -> _NativeIASResult: + """Compute practical minimum mode in canonical orientation and native units.""" + + cutoff_a = profile_a.cutoff_radius_bohr + cutoff_b = profile_b.cutoff_radius_bohr + separation, regime = _cutoff_regime(distance_bohr, cutoff_a, cutoff_b) + + if profile_a.profile.atomic_number == profile_b.profile.atomic_number: + position = distance_bohr / 2.0 + rho_a, rho_b, rho_sum = _component_values( + profile_a, + profile_b, + distance_bohr, + position, + ) + return _NativeIASResult( + requested_mode="minimum", + method="homonuclear_midpoint", + status="low_density_gap" if regime == "gap" else "ok", + position_bohr=position, + rho_a=rho_a, + rho_b=rho_b, + rho_sum=rho_sum, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + search_resolution_bohr=IAS_MINIMUM_RESOLUTION_BOHR, + search_converged=True, + search_passes=0, + ) + + if regime in ("contact", "gap"): + return _NativeIASResult( + requested_mode="minimum", + method="none", + status="low_density_gap", + position_bohr=None, + rho_a=None, + rho_b=None, + rho_sum=None, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + search_resolution_bohr=IAS_MINIMUM_RESOLUTION_BOHR, + search_converged=None, + search_passes=0, + ) + + overlap_left = max(0.0, distance_bohr - cutoff_b) + overlap_right = min(distance_bohr, cutoff_a) + + equal_position, _ = _equal_contribution_position( + profile_a, + profile_b, + distance_bohr, + ) + coarse = _minimum_grid_pass( + profile_a, + profile_b, + distance_bohr, + overlap_left, + overlap_right, + _MINIMUM_INITIAL_SPACING_BOHR, + equal_position, + ) + fine = _minimum_grid_pass( + profile_a, + profile_b, + distance_bohr, + overlap_left, + overlap_right, + _MINIMUM_CONFIRM_SPACING_BOHR, + equal_position, + ) + + executed_passes = [coarse, fine] + final_spacing_bohr = fine.max_spacing_bohr + search_passes = 2 + search_converged = True + if not _selections_compatible(coarse.selected, fine.selected): + fallback = _minimum_grid_pass( + profile_a, + profile_b, + distance_bohr, + overlap_left, + overlap_right, + _MINIMUM_FALLBACK_SPACING_BOHR, + equal_position, + ) + executed_passes.append(fallback) + final_spacing_bohr = fallback.max_spacing_bohr + search_passes = 3 + search_converged = _selections_compatible( + fine.selected, + fallback.selected, + ) + + final_pass = _MinimumPass( + max_spacing_bohr=final_spacing_bohr, + candidates=_combine_confirmed_minimum_candidates( + *executed_passes, + candidate_filter=lambda candidate: ( + overlap_left < candidate.position_bohr < overlap_right + and _minimum_candidate_is_publicly_strict( + candidate, + distance_bohr, + cutoff_a, + cutoff_b, + public_presentations, + ) + ), + ), + ) + + selected = final_pass.selected + if selected is None: + return _NativeIASResult( + requested_mode="minimum", + method="none", + status="no_resolved_interior_minimum", + position_bohr=None, + rho_a=None, + rho_b=None, + rho_sum=None, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + search_resolution_bohr=final_pass.max_spacing_bohr, + search_converged=search_converged, + search_passes=search_passes, + ) + + alternative = ( + final_pass.candidates[1] if len(final_pass.candidates) > 1 else None + ) + relative_depth_gap = None + ambiguous = False + if alternative is not None: + relative_depth_gap = max( + 0.0, + (alternative.density - selected.density) / selected.density, + ) + ambiguous = relative_depth_gap <= _COMPETITIVE_RELATIVE_DEPTH + + status: _IASStatus = "ambiguous_competing_minima" if ambiguous else "ok" + if not search_converged: + status = "search_unstable" + + boundary_density = min( + _objective(profile_a, profile_b, distance_bohr, 0.0), + _objective(profile_a, profile_b, distance_bohr, distance_bohr), + ) + if _less_beyond_roundoff(boundary_density, selected.density): + status = "boundary_dominated" + + rho_a, rho_b, rho_sum = _component_values( + profile_a, + profile_b, + distance_bohr, + selected.position_bohr, + ) + return _NativeIASResult( + requested_mode="minimum", + method="promolecular_density_minimum", + status=status, + position_bohr=selected.position_bohr, + rho_a=rho_a, + rho_b=rho_b, + rho_sum=rho_sum, + cutoff_radius_a_bohr=cutoff_a, + cutoff_radius_b_bohr=cutoff_b, + contour_separation_bohr=separation, + cutoff_regime=regime, + alternative_position_bohr=( + None if alternative is None else alternative.position_bohr + ), + alternative_rho_sum=( + None if alternative is None else alternative.density + ), + relative_depth_gap=relative_depth_gap, + ambiguous=ambiguous, + search_resolution_bohr=final_pass.max_spacing_bohr, + search_converged=search_converged, + search_passes=search_passes, + ) + + +def _validate_pair_distance( + distance: float, + *, + distance_unit: str, +) -> tuple[float, float]: + """Validate a public pair distance and return requested/native values.""" + + if distance_unit not in ("angstrom", "bohr"): + raise ValueError(f"unknown distance unit: {distance_unit!r}") + if isinstance(distance, bool): + raise ValueError("distance must be a finite positive scalar") + try: + requested_distance = float(distance) + except (TypeError, ValueError) as exc: + raise ValueError("distance must be a finite positive scalar") from exc + if not math.isfinite(requested_distance): + raise ValueError("distance must be finite") + if requested_distance <= 0.0: + raise ValueError("distance must be strictly positive") + + public_max = ( + 20.0 if distance_unit == "bohr" else 20.0 * BOHR_TO_ANGSTROM + ) + if requested_distance > public_max: + raise ValueError("distance exceeds the public limit of 20 bohr") + if distance_unit == "bohr": + return requested_distance, requested_distance + return requested_distance, min(requested_distance / BOHR_TO_ANGSTROM, 20.0) + + +def _density_output_factor(density_unit: str) -> float: + """Validate an output density unit and return its native scale factor.""" + + return _density_from_native(1.0, density_unit=density_unit) + + +def _assemble_pairwise_result( + native: _NativeIASResult, + *, + requested_profile_a: ProatomicDensityProfile, + requested_profile_b: ProatomicDensityProfile, + canonical_profile_a: _PreparedPairwiseProfile, + canonical_profile_b: _PreparedPairwiseProfile, + canonical_was_reversed: bool, + requested_distance: float, + distance_bohr: float, + distance_unit: str, + density_unit: str, +) -> IASPositionResult: + """Apply requested orientation and units to one complete native result.""" + + distance_factor = 1.0 if distance_unit == "bohr" else BOHR_TO_ANGSTROM + density_factor = _density_output_factor(density_unit) + + def oriented_coordinate_pair( + canonical_position: float | None, + ) -> tuple[float | None, float | None, float | None]: + if canonical_position is None: + return None, None, None + canonical_from_a = ( + requested_distance / 2.0 + if native.method == "homonuclear_midpoint" + else canonical_position * distance_factor + ) + canonical_from_b = requested_distance - canonical_from_a + canonical_fraction = canonical_from_a / requested_distance + if canonical_was_reversed: + return ( + canonical_from_b, + canonical_from_a, + canonical_from_b / requested_distance, + ) + return canonical_from_a, canonical_from_b, canonical_fraction + + position_a, position_b, fraction_a = oriented_coordinate_pair( + native.position_bohr + ) + alternative_a, alternative_b, _ = oriented_coordinate_pair( + native.alternative_position_bohr + ) + + if canonical_was_reversed: + rho_a_native, rho_b_native = native.rho_b, native.rho_a + cutoff_a_native = native.cutoff_radius_b_bohr + cutoff_b_native = native.cutoff_radius_a_bohr + else: + rho_a_native, rho_b_native = native.rho_a, native.rho_b + cutoff_a_native = native.cutoff_radius_a_bohr + cutoff_b_native = native.cutoff_radius_b_bohr + + dominant_atom = None + dominant_atom_role: _DominantAtomRole | None = None + if native.dominant_side is not None: + canonical_dominant = ( + canonical_profile_a + if native.dominant_side == "a" + else canonical_profile_b + ) + dominant_atom = canonical_dominant.profile.symbol + dominant_is_requested_a = ( + native.dominant_side == "a" and not canonical_was_reversed + ) or (native.dominant_side == "b" and canonical_was_reversed) + dominant_atom_role = "atom_a" if dominant_is_requested_a else "atom_b" + + return IASPositionResult( + atom_a=requested_profile_a.symbol, + atom_b=requested_profile_b.symbol, + distance=requested_distance, + distance_unit=distance_unit, + density_unit=density_unit, + requested_mode=native.requested_mode, + method=native.method, + status=native.status, + position_from_a=position_a, + position_from_b=position_b, + fraction_from_a=fraction_a, + rho_a=None if rho_a_native is None else rho_a_native * density_factor, + rho_b=None if rho_b_native is None else rho_b_native * density_factor, + rho_sum=( + None if native.rho_sum is None else native.rho_sum * density_factor + ), + cutoff_density=PROATOMIC_TAIL_CUTOFF * density_factor, + cutoff_radius_a=cutoff_a_native * distance_factor, + cutoff_radius_b=cutoff_b_native * distance_factor, + contour_separation=native.contour_separation_bohr * distance_factor, + cutoff_regime=native.cutoff_regime, + dominant_atom=dominant_atom, + dominant_atom_role=dominant_atom_role, + alternative_position_from_a=alternative_a, + alternative_position_from_b=alternative_b, + alternative_rho_sum=( + None + if native.alternative_rho_sum is None + else native.alternative_rho_sum * density_factor + ), + relative_depth_gap=native.relative_depth_gap, + ambiguous=native.ambiguous, + search_resolution=( + None + if native.search_resolution_bohr is None + else native.search_resolution_bohr * distance_factor + ), + search_converged=native.search_converged, + search_passes=native.search_passes, + dataset_id=requested_profile_a.ref.set_id, + interpolation_contract=requested_profile_a.interpolation_contract, + pairwise_contract=_PAIRWISE_CONTRACT, + ) + + +def _estimate_pairwise( + atom_a: str | int | None, + atom_b: str | int | None, + distance: float, + *, + mode: Literal["boundary", "minimum"], + distance_unit: str, + density_unit: str, + set_id: str, +) -> IASPositionResult | None: + """Validate, canonicalize, compute, and assemble one pairwise estimate.""" + + requested_distance, distance_bohr = _validate_pair_distance( + distance, + distance_unit=distance_unit, + ) + _density_output_factor(density_unit) + + dataset, _ = _resolve_proatomic_density_set(set_id) + + resolved_a = _resolve_density_element(atom_a) + resolved_b = _resolve_density_element(atom_b) + if resolved_a is None or resolved_b is None: + return None + + if dataset.get(resolved_a.z) is None or dataset.get(resolved_b.z) is None: + return None + requested_profile_a = _get_profile_cached(dataset.ref, resolved_a.z) + requested_profile_b = _get_profile_cached(dataset.ref, resolved_b.z) + + prepared_a = _prepared_pairwise_profile(requested_profile_a) + prepared_b = _prepared_pairwise_profile(requested_profile_b) + canonical_was_reversed = ( + prepared_a.profile.atomic_number > prepared_b.profile.atomic_number + ) + if canonical_was_reversed: + canonical_a, canonical_b = prepared_b, prepared_a + else: + canonical_a, canonical_b = prepared_a, prepared_b + + contact_distance_bohr = ( + canonical_a.cutoff_radius_bohr + canonical_b.cutoff_radius_bohr + ) + contact_distances_requested: tuple[float, ...] + if distance_unit == "bohr": + contact_distances_requested = (contact_distance_bohr,) + else: + contact_distances_requested = ( + contact_distance_bohr * BOHR_TO_ANGSTROM, + canonical_a.cutoff_radius_bohr * BOHR_TO_ANGSTROM + + canonical_b.cutoff_radius_bohr * BOHR_TO_ANGSTROM, + ) + if requested_distance in contact_distances_requested: + # Preserve an exact physical cutoff contact across the named input + # units without broadening the binding native ``d_c > 0`` branch. + distance_bohr = contact_distance_bohr + + if mode == "boundary": + native = _native_boundary_estimate( + canonical_a, + canonical_b, + distance_bohr, + ) + else: + distance_factor = ( + 1.0 if distance_unit == "bohr" else BOHR_TO_ANGSTROM + ) + native = _native_minimum_estimate( + canonical_a, + canonical_b, + distance_bohr, + public_presentations=((requested_distance, distance_factor),), + ) + + return _assemble_pairwise_result( + native, + requested_profile_a=requested_profile_a, + requested_profile_b=requested_profile_b, + canonical_profile_a=canonical_a, + canonical_profile_b=canonical_b, + canonical_was_reversed=canonical_was_reversed, + requested_distance=requested_distance, + distance_bohr=distance_bohr, + distance_unit=distance_unit, + density_unit=density_unit, + ) + + +def estimate_proatomic_boundary( + atom_a: str | int | None, + atom_b: str | int | None, + distance: float, + *, + distance_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> IASPositionResult | None: + """Estimate a stable pairwise neutral-proatom boundary. + + Args: + atom_a: First element symbol or atomic number. D/T map to H. + atom_b: Second element symbol or atomic number. D/T map to H. + distance: Finite positive pair distance in `distance_unit`, no greater + than 20 bohr after conversion. + distance_unit: ``"angstrom"`` (default) or ``"bohr"``. Returned + coordinates and cutoff radii use the same unit. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"`` for reported density fields. + set_id: Canonical proatomic-density set ID or alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + An [IASPositionResult][atomref.proatoms.IASPositionResult] oriented from + atom A toward atom B, or `None` when either profile is invalid or + unavailable. A valid one-atom-dominance case returns a typed result with + no coordinate. + + Raises: + ValueError: If distance or a unit is outside the public contract. + DatasetError: If the selected dataset is unknown, malformed, or + non-radial. + + Examples: + >>> result = estimate_proatomic_boundary("C", "O", 1.5) + >>> result is not None + True + >>> result.requested_mode + 'boundary' + + Notes: + Homonuclear pairs use the exact midpoint. Overlapping unlike profiles + use equal neutral-proatom density; separated fixed-cutoff contours use + the midpoint of their gap. This stable divider is not a molecular QTAIM + zero-flux surface. + """ + + return _estimate_pairwise( + atom_a, + atom_b, + distance, + mode="boundary", + distance_unit=distance_unit, + density_unit=density_unit, + set_id=set_id, + ) + + +def estimate_promolecular_density_minimum( + atom_a: str | int | None, + atom_b: str | int | None, + distance: float, + *, + distance_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> IASPositionResult | None: + """Estimate one practically resolved promolecular line-density minimum. + + Args: + atom_a: First element symbol or atomic number. D/T map to H. + atom_b: Second element symbol or atomic number. D/T map to H. + distance: Finite positive pair distance in `distance_unit`, no greater + than 20 bohr after conversion. + distance_unit: ``"angstrom"`` (default) or ``"bohr"``. Returned + coordinates and search resolution use the same unit. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"`` for reported density fields. + set_id: Canonical proatomic-density set ID or alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + An [IASPositionResult][atomref.proatoms.IASPositionResult] oriented from + atom A toward atom B, or `None` when either profile is invalid or + unavailable. A valid pair with no resolved strict-interior minimum + returns a typed result with no coordinate. + + Raises: + ValueError: If distance or a unit is outside the public contract. + DatasetError: If the selected dataset is unknown, malformed, or + non-radial. + + Examples: + >>> result = estimate_promolecular_density_minimum("C", "O", 1.5) + >>> result is not None + True + >>> result.requested_mode + 'minimum' + + Notes: + Search is confined to the interval where both neutral components meet + [PROATOMIC_TAIL_CUTOFF][atomref.proatoms.PROATOMIC_TAIL_CUTOFF]. It has + declared resolution + [IAS_MINIMUM_RESOLUTION_BOHR][atomref.proatoms.IAS_MINIMUM_RESOLUTION_BOHR], + exposes only strict-interior unlike-atom candidates, and never falls + back to boundary mode. It is a practical + neutral-promolecular proxy, not an exact molecular-density critical + point. + """ + + return _estimate_pairwise( + atom_a, + atom_b, + distance, + mode="minimum", + distance_unit=distance_unit, + density_unit=density_unit, + set_id=set_id, + ) + + +def estimate_ias_position( + atom_a: str | int | None, + atom_b: str | int | None, + distance: float, + *, + mode: Literal["boundary", "minimum"] = "boundary", + distance_unit: Literal["angstrom", "bohr"] = "angstrom", + density_unit: Literal[ + "electron/bohr^3", "electron/angstrom^3" + ] = "electron/bohr^3", + set_id: str = DEFAULT_PROATOMIC_DENSITY_SET, +) -> IASPositionResult | None: + """Dispatch explicitly to one pairwise neutral-proatom mode. + + Args: + atom_a: First element symbol or atomic number. D/T map to H. + atom_b: Second element symbol or atomic number. D/T map to H. + distance: Finite positive pair distance in `distance_unit`, no greater + than 20 bohr after conversion. + mode: ``"boundary"`` (default) or ``"minimum"``. + distance_unit: ``"angstrom"`` (default) or ``"bohr"``. + density_unit: ``"electron/bohr^3"`` (default) or + ``"electron/angstrom^3"``. + set_id: Canonical proatomic-density set ID or alias. Defaults to + [DEFAULT_PROATOMIC_DENSITY_SET][atomref.proatoms.DEFAULT_PROATOMIC_DENSITY_SET]. + + Returns: + The same [IASPositionResult][atomref.proatoms.IASPositionResult] or + missing-profile `None` produced by the corresponding direct estimator. + + Raises: + ValueError: If `mode`, distance, or a unit is outside the public + contract. + DatasetError: If the selected dataset is unknown, malformed, or + non-radial. + + Examples: + >>> direct = estimate_proatomic_boundary("C", "O", 1.5) + >>> selected = estimate_ias_position("C", "O", 1.5) + >>> selected == direct + True + + Notes: + The minimum mode never silently falls back to boundary mode. The two + modes are related scientific approximations, not interchangeable names + for one calculation. + """ + + if mode not in ("boundary", "minimum"): + raise ValueError(f"unknown IAS position mode: {mode!r}") + return _estimate_pairwise( + atom_a, + atom_b, + distance, + mode=mode, + distance_unit=distance_unit, + density_unit=density_unit, + set_id=set_id, + ) diff --git a/src/atomref/radii.py b/src/atomref/radii.py index b33877f..dd5cdbd 100644 --- a/src/atomref/radii.py +++ b/src/atomref/radii.py @@ -5,7 +5,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass, field import math -from typing import Literal +from typing import Literal, SupportsFloat, SupportsIndex, cast from .elements import canonicalize_element_symbol, get_element, is_valid_element_symbol from .errors import PolicyError @@ -20,15 +20,20 @@ DatasetInfo, DatasetRef, ElementScalarSet, - get_builtin_set, get_dataset_info, list_dataset_ids, list_dataset_infos, + resolve_scalar_dataset_like, ) from .transfer import LinearFit, LinearTransfer, SubstitutionTransfer, TransferModel RadiiKind = Literal["covalent", "van_der_waals"] +"""Supported radii quantity selector.""" + RadiiSet = ElementScalarSet +"""Typing alias for an immutable element-indexed radii dataset.""" + +_FloatLike = str | bytes | bytearray | memoryview | SupportsFloat | SupportsIndex _KIND_TO_QUANTITY = { @@ -41,8 +46,26 @@ class RadiiPolicy: """Policy wrapper specialized for radii lookup. - ``kind`` determines the target quantity, while the remaining fields mirror - the generic :class:`atomref.policy.ValuePolicy` interface. + Attributes: + kind: Target radii quantity, ``"covalent"`` or ``"van_der_waals"``. + base_set: Packaged set ID or custom + [ElementScalarSet][atomref.registry.ElementScalarSet]. + transfers: Ordered substitution or linear-transfer rules. Defaults to + no transfers. + overrides: Explicit finite, nonnegative element values checked before + the base set. Defaults to an empty mapping. + fallback: Final finite, nonnegative value, or `None`. Defaults to `None`. + + Examples: + >>> import atomref as ar + >>> policy = ar.RadiiPolicy(kind="covalent", base_set="cordero2008") + >>> ar.get_covalent_radius("C", policy=policy) + 0.76 + + Notes: + Packaged radii use angstrom. Custom sets, overrides, fallbacks, and + transfer sources must use compatible units because policies do not + perform unit conversion. """ kind: RadiiKind @@ -52,9 +75,20 @@ class RadiiPolicy: fallback: float | None = None def as_value_policy(self) -> ValuePolicy[str]: - """Convert the radii policy into the generic scalar-value policy.""" + """Convert this wrapper into the generic scalar policy. + + Returns: + An element-domain [ValuePolicy][atomref.policy.ValuePolicy] + preserving the configured rule order. + + Raises: + DatasetError: If a packaged set is unknown or non-scalar. + PolicyError: If `kind`, base quantity, override, or fallback is + invalid. + """ quantity = _quantity_for_kind(self.kind) + base: DatasetRef | ElementScalarSet if isinstance(self.base_set, ElementScalarSet): if self.base_set.ref.quantity != quantity: msg = ( @@ -92,7 +126,12 @@ def as_value_policy(self) -> ValuePolicy[str]: @dataclass(frozen=True, slots=True) class RadiiElementAssessment: - """Per-element row in a radii policy assessment report.""" + """Per-element row in a radii policy assessment report. + + Attributes: + symbol: Canonical element symbol. + lookup: Full lookup result for that element. + """ symbol: str lookup: LookupResult @@ -100,7 +139,27 @@ class RadiiElementAssessment: @dataclass(frozen=True, slots=True) class RadiiPolicyAssessment: - """Summary of how a radii policy behaved over a set of elements.""" + """Summary of how a radii policy behaved over a set of elements. + + Attributes: + kind: Assessed radii quantity. + policy: Policy that was assessed. + elements: Canonical, deduplicated symbols in atomic-number order. + n_elements: Number of assessed elements. + n_override: Results supplied by explicit overrides. + n_base: Results supplied directly by the base set. + n_transfer_substitution: Results supplied by substitution transfers. + n_transfer_linear: Results supplied by linear transfers. + n_fallback: Results supplied by the fallback. + n_missing: Elements without a resolved value. + n_placeholders: Returned values equal to a declared placeholder. + missing_symbols: Symbols counted by `n_missing`. + placeholder_symbols: Symbols counted by `n_placeholders`. + fits: Successful linear-fit diagnostics for configured transfers. + warnings: Fit-assessment errors retained as report warnings. + per_element: Detailed rows when assessment used `detail=True`; otherwise + an empty tuple. + """ kind: RadiiKind policy: RadiiPolicy @@ -126,12 +185,12 @@ class RadiiPolicyAssessment: def _coerce_non_negative_radii_value(value: object, *, what: str) -> float: """Validate a radii-like policy number. - The generic :class:`atomref.policy.ValuePolicy` accepts any finite scalar. + The generic [ValuePolicy][atomref.ValuePolicy] accepts any finite scalar. Radii-specific convenience helpers are stricter and reject negative values. """ try: - out = float(value) + out = float(cast(_FloatLike, value)) except (TypeError, ValueError) as exc: raise PolicyError(f"{what} must be a finite float") from exc if not math.isfinite(out): @@ -170,9 +229,14 @@ def _normalize_assessment_elements(elements: Iterable[str]) -> tuple[str, ...]: if not is_valid_element_symbol(sym): raise ValueError(f"invalid element symbol: {sym!r}") symbols.add(sym) - return tuple( - sorted(symbols, key=lambda s: get_element(s).z if get_element(s) else 0) - ) + + def atomic_number(symbol: str) -> int: + element = get_element(symbol) + if element is None: # pragma: no cover - validated above + raise ValueError(f"invalid element symbol: {symbol!r}") + return element.z + + return tuple(sorted(symbols, key=atomic_number)) def list_radii_sets( @@ -180,7 +244,19 @@ def list_radii_sets( *, usage_role: str | None = None, ) -> tuple[str, ...]: - """List packaged radii-set ids for one radii kind.""" + """List packaged radii-set IDs for one radii kind. + + Args: + kind: ``"covalent"`` or ``"van_der_waals"``. + usage_role: Optional case-insensitive metadata-role filter. + + Returns: + Canonical set IDs in curated registry order. + + Raises: + PolicyError: If `kind` is unsupported. + DatasetError: If registry metadata is malformed. + """ return list_dataset_ids(_quantity_for_kind(kind), usage_role=usage_role) @@ -190,21 +266,59 @@ def list_radii_set_infos( *, usage_role: str | None = None, ) -> tuple[DatasetInfo, ...]: - """Return packaged metadata objects for radii sets of one kind.""" + """Return packaged metadata objects for radii sets of one kind. + + Args: + kind: ``"covalent"`` or ``"van_der_waals"``. + usage_role: Optional case-insensitive metadata-role filter. + + Returns: + Immutable [DatasetInfo][atomref.registry.DatasetInfo] objects in curated + registry order. + + Raises: + PolicyError: If `kind` is unsupported. + DatasetError: If registry metadata is malformed. + """ return list_dataset_infos(_quantity_for_kind(kind), usage_role=usage_role) def get_radii_set_info(kind: RadiiKind, set_id: str) -> DatasetInfo: - """Return metadata for one packaged radii set.""" + """Return metadata for one packaged radii set. + + Args: + kind: ``"covalent"`` or ``"van_der_waals"``. + set_id: Canonical packaged set ID or accepted alias. + + Returns: + Curated metadata, including angstrom units and provenance. + + Raises: + PolicyError: If `kind` is unsupported. + DatasetError: If `set_id` is unknown or metadata is malformed. + """ return get_dataset_info(DatasetRef(_quantity_for_kind(kind), set_id)) def get_radii_set(kind: RadiiKind, set_id: str) -> RadiiSet: - """Load one packaged radii set as an :class:`ElementScalarSet`.""" + """Load one packaged radii set as an + [ElementScalarSet][atomref.registry.ElementScalarSet]. - return get_builtin_set(DatasetRef(_quantity_for_kind(kind), set_id)) + Args: + kind: ``"covalent"`` or ``"van_der_waals"``. + set_id: Canonical packaged set ID or accepted alias. + + Returns: + A cached immutable scalar set whose values are in angstrom. + + Raises: + PolicyError: If `kind` is unsupported. + DatasetError: If the set is unknown, malformed, or non-scalar. + """ + + return resolve_scalar_dataset_like(DatasetRef(_quantity_for_kind(kind), set_id)) def _validate_policy_kind(policy: RadiiPolicy, *, expected: RadiiKind) -> None: @@ -225,7 +339,24 @@ def lookup_covalent_radius( *, policy: RadiiPolicy | None = None, ) -> LookupResult: - """Resolve a covalent radius together with provenance information.""" + """Resolve a covalent radius together with provenance. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: Covalent [RadiiPolicy][atomref.radii.RadiiPolicy]; `None` selects + [DEFAULT_COVALENT_POLICY][atomref.DEFAULT_COVALENT_POLICY]. + + Returns: + Lookup result whose value is in angstrom, or an explicit missing result. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If the policy has the wrong kind or invalid configuration. + + Examples: + >>> lookup_covalent_radius("C").value + 0.76 + """ active = DEFAULT_COVALENT_POLICY if policy is None else policy _validate_policy_kind(active, expected="covalent") @@ -237,7 +368,20 @@ def get_covalent_radius( *, policy: RadiiPolicy | None = None, ) -> float | None: - """Return only the selected covalent-radius value, without provenance.""" + """Return only the selected covalent radius. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: Covalent [RadiiPolicy][atomref.radii.RadiiPolicy]; `None` selects + [DEFAULT_COVALENT_POLICY][atomref.DEFAULT_COVALENT_POLICY]. + + Returns: + Selected radius in angstrom, or `None` when resolution is missing. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If the policy has the wrong kind or invalid configuration. + """ active = DEFAULT_COVALENT_POLICY if policy is None else policy _validate_policy_kind(active, expected="covalent") @@ -249,7 +393,20 @@ def lookup_vdw_radius( *, policy: RadiiPolicy | None = None, ) -> LookupResult: - """Resolve a van der Waals radius together with provenance information.""" + """Resolve a van der Waals radius together with provenance. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: van der Waals [RadiiPolicy][atomref.radii.RadiiPolicy]; `None` + selects [DEFAULT_VDW_POLICY][atomref.DEFAULT_VDW_POLICY]. + + Returns: + Lookup result whose value is in angstrom, or an explicit missing result. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If the policy has the wrong kind or invalid configuration. + """ active = DEFAULT_VDW_POLICY if policy is None else policy _validate_policy_kind(active, expected="van_der_waals") @@ -261,7 +418,20 @@ def get_vdw_radius( *, policy: RadiiPolicy | None = None, ) -> float | None: - """Return only the selected van der Waals-radius value, without provenance.""" + """Return only the selected van der Waals radius. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + policy: van der Waals [RadiiPolicy][atomref.radii.RadiiPolicy]; `None` + selects [DEFAULT_VDW_POLICY][atomref.DEFAULT_VDW_POLICY]. + + Returns: + Selected radius in angstrom, or `None` when resolution is missing. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If the policy has the wrong kind or invalid configuration. + """ active = DEFAULT_VDW_POLICY if policy is None else policy _validate_policy_kind(active, expected="van_der_waals") @@ -274,7 +444,33 @@ def assess_radii_policy( policy: RadiiPolicy, detail: bool = False, ) -> RadiiPolicyAssessment: - """Assess how a radii policy resolves values over a set of elements.""" + """Assess how a radii policy resolves values over a set of elements. + + Args: + elements: Element tokens to normalize, deduplicate, and sort by atomic + number. + policy: Radii policy to evaluate. + detail: Include a + [RadiiElementAssessment][atomref.radii.RadiiElementAssessment] for + each element when `True`. Defaults to `False`. + + Returns: + Counts, missing/placeholder symbols, fit summaries, warnings, and + optional per-element detail in a + [RadiiPolicyAssessment][atomref.radii.RadiiPolicyAssessment]. + + Raises: + ValueError: If an element token is missing or invalid. + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If policy or transfer configuration is invalid. + + Examples: + >>> report = assess_radii_policy( + ... ["C", "O"], policy=DEFAULT_COVALENT_POLICY, detail=True + ... ) + >>> report.n_elements, len(report.per_element) + (2, 2) + """ elems = _normalize_assessment_elements(elements) value_policy = policy.as_value_policy() diff --git a/src/atomref/registry.py b/src/atomref/registry.py index b17b941..4254cff 100644 --- a/src/atomref/registry.py +++ b/src/atomref/registry.py @@ -1,4 +1,4 @@ -"""Dataset registry and packaged element-scalar set loading.""" +"""Dataset registry and packaged element-set loading.""" from __future__ import annotations @@ -7,24 +7,41 @@ import csv from functools import lru_cache from importlib import resources +import io import json import math +import stat from types import MappingProxyType import unicodedata +from typing import SupportsFloat, SupportsIndex, cast +import zipfile +import zlib from .elements import canonicalize_element_symbol, get_element, iter_elements from .errors import DatasetError QuantityId = str +"""Typing alias for a registry quantity identifier.""" + DomainId = str +"""Typing alias for a registry lookup-domain identifier.""" + +_FloatLike = str | bytes | bytearray | memoryview | SupportsFloat | SupportsIndex @dataclass(frozen=True, slots=True) class DatasetRef: """Stable reference to a packaged dataset. - The ``quantity`` identifies the operational property family, while - ``set_id`` names a specific curated dataset within that family. + Attributes: + quantity: Operational property family, such as ``"covalent_radius"`` + or ``"proatomic_density"``. + set_id: Canonical dataset identifier or an accepted alias when the + reference is passed to a registry lookup. + + Examples: + >>> DatasetRef("covalent_radius", "cordero2008") + DatasetRef(quantity='covalent_radius', set_id='cordero2008') """ quantity: QuantityId @@ -33,7 +50,18 @@ class DatasetRef: @dataclass(frozen=True, slots=True) class Reference: - """Bibliographic record attached to packaged dataset metadata.""" + """Bibliographic record attached to packaged dataset metadata. + + Attributes: + authors: Author string as recorded by the curated metadata. + year: Publication year. + title: Work title. + venue: Journal, repository, or other publication venue. + doi: DOI without an implied URL prefix. + url: Source or publication URL. + publisher: Publisher or archive name. + note: Additional attribution or interpretation note. + """ authors: str | None = None year: int | None = None @@ -47,7 +75,17 @@ class Reference: @dataclass(frozen=True, slots=True) class CoverageInfo: - """Coverage summary for an element-indexed scalar dataset.""" + """Coverage summary for an element-indexed dataset. + + Attributes: + n_values: Number of non-missing element values or profiles. + z_min: Lowest covered atomic number, or `None` for empty coverage. + z_max: Highest covered atomic number, or `None` for empty coverage. + has_placeholders: Whether at least one covered scalar equals the + dataset's declared placeholder value. + covered_z: Covered atomic numbers in increasing order. + missing_z: Missing atomic numbers in increasing order. + """ n_values: int z_min: int | None = None @@ -59,7 +97,15 @@ class CoverageInfo: @dataclass(frozen=True, slots=True) class QuantityInfo: - """Metadata shared by all datasets that belong to one quantity.""" + """Metadata shared by all datasets that belong to one quantity. + + Attributes: + quantity: Registry quantity identifier. + domain: Lookup domain. The current resolver supports ``"element"``. + units: Scientific units shared by the quantity, or `None` when the + quantity is unitless or unspecified. + description: Human-readable quantity description. + """ quantity: QuantityId domain: DomainId @@ -71,9 +117,29 @@ class QuantityInfo: class DatasetInfo: """Curated metadata for one packaged dataset. - This object keeps operational classification such as ``ref.quantity`` and - ``usage_role`` separate from scientific classification such as - ``semantic_class`` and ``phase_context``. + This object keeps operational classification such as `ref.quantity` and + `usage_role` separate from scientific classification such as + `semantic_class` and `phase_context`. + + Attributes: + ref: Canonical quantity and dataset identifier. + domain: Lookup domain, currently ``"element"`` for packaged data. + units: Units of stored scalar values or density profiles. + name: Human-readable dataset name. + description: Concise scientific description. + usage_role: Operational role such as ``"target"`` or ``"support"``. + semantic_class: Scientific class of the values. + origin_class: Origin category used during curation. + phase_context: Physical phase or environment associated with values. + method_summary: Concise computational or experimental method. + placeholder_value: Declared scalar placeholder, if one exists. + extraction_source: Record of the upstream extraction source. + aliases: Accepted alternative dataset identifiers. + references: Bibliographic and source records. + notes: Additional immutable metadata notes. + storage: Read-only packaged-storage description, or `None` for a custom + in-memory set. + coverage: Element-coverage summary, when available. """ ref: DatasetRef @@ -97,7 +163,19 @@ class DatasetInfo: @dataclass(frozen=True, slots=True) class ElementScalarSet: - """Element-indexed scalar dataset stored densely by atomic number.""" + """Immutable element-indexed scalar dataset stored by atomic number. + + Attributes: + ref: Dataset identity. + info: Curated metadata, including the scientific units. + values_by_z: Dense immutable tuple indexed by atomic number. Index zero + is unused and missing elements contain `None`. + + Notes: + Scalar values have the units recorded by `info.units`. Policies and + transfers do not perform unit conversion, so custom sources combined in + one policy must use compatible units. + """ ref: DatasetRef info: DatasetInfo @@ -120,7 +198,41 @@ def from_mapping( notes: Iterable[str] = (), placeholder_value: float | None = None, ) -> "ElementScalarSet": - """Build a custom element-domain dataset from a symbol-keyed mapping.""" + """Build a custom element-domain dataset from a symbol-keyed mapping. + + Args: + ref: Stable identity for the custom dataset. + values: Element symbols mapped to finite scalar values or `None`. + Symbols are canonicalized, and D/T map to H. + name: Human-readable dataset name. + units: Scientific units for every non-missing value, or `None`. + description: Optional scientific description. + usage_role: Operational role. Defaults to ``"user"``. + semantic_class: Scientific classification. Defaults to ``"user"``. + origin_class: Origin classification. Defaults to ``"user"``. + phase_context: Optional physical phase or environment. + references: Bibliographic records to preserve with the set. + notes: Additional metadata notes. + placeholder_value: Optional finite scalar used as a placeholder. + + Returns: + A frozen [ElementScalarSet][atomref.registry.ElementScalarSet] with + coverage metadata computed for the packaged periodic table. + + Raises: + DatasetError: If an element key is invalid, two keys normalize to + the same element, or a value is not finite. + + Examples: + >>> custom = ElementScalarSet.from_mapping( + ... ref=DatasetRef("covalent_radius", "my_set"), + ... values={"C": 0.76, "O": 0.66}, + ... name="My radii", + ... units="angstrom", + ... ) + >>> custom.get("O") + 0.66 + """ n_z = max(e.z for e in iter_elements()) values_by_z: list[float | None] = [None] * (n_z + 1) @@ -137,6 +249,8 @@ def from_mapping( for key, value in values.items(): sym = _normalize_element_domain_symbol(key) + if sym is None: + raise DatasetError(f"invalid element symbol in custom set: {key!r}") elem = get_element(sym) if elem is None: raise DatasetError(f"invalid element symbol in custom set: {key!r}") @@ -193,7 +307,15 @@ def from_mapping( return cls(ref=ref, info=info, values_by_z=tuple(values_by_z)) def get(self, symbol: str | None) -> float | None: - """Return the scalar value for ``symbol`` or ``None`` if absent.""" + """Return one element's scalar value. + + Args: + symbol: Symbol-like element token, or `None`. D/T map to H. + + Returns: + The stored scalar in `info.units`, or `None` for an invalid or + uncovered element. + """ sym = _normalize_element_domain_symbol(symbol) elem = get_element(sym) @@ -202,7 +324,56 @@ def get(self, symbol: str | None) -> float | None: return self.values_by_z[elem.z] -DatasetLike = DatasetRef | ElementScalarSet +@dataclass(frozen=True, slots=True) +class ElementRadialSet: + """Immutable element-indexed radial profiles sampled on one shared grid. + + Attributes: + ref: Dataset identity. + info: Curated metadata and storage description. + radii: Shared immutable radial grid in the storage-declared coordinate + unit. + profiles_by_z: Dense immutable tuple of profiles indexed by atomic + number. Index zero is unused and missing profiles contain `None`. + """ + + ref: DatasetRef + info: DatasetInfo + radii: tuple[float, ...] + profiles_by_z: tuple[tuple[float, ...] | None, ...] + + def get(self, element: str | int | None) -> tuple[float, ...] | None: + """Return the immutable sampled profile for one element. + + Args: + element: Symbol-like token, integer atomic number, or `None`. D/T + map to H; booleans are rejected despite being integer subclasses. + + Returns: + The stored profile in the density units described by `info`, or + `None` for an invalid or uncovered element. + """ + + if isinstance(element, int) and not isinstance(element, bool): + z = element + elif isinstance(element, str) or element is None: + sym = _normalize_element_domain_symbol(element) + elem = get_element(sym) + if elem is None: + return None + z = elem.z + else: + return None + if z <= 0 or z >= len(self.profiles_by_z): + return None + return self.profiles_by_z[z] + + +BuiltinSet = ElementScalarSet | ElementRadialSet +"""Union of packaged scalar and radial dataset payloads.""" + +ScalarDatasetLike = DatasetRef | ElementScalarSet +"""Typing alias for packaged references and custom scalar datasets.""" _DASH_TRANSLATION = str.maketrans( @@ -244,7 +415,7 @@ def _freeze_json_like(value: object) -> object: Registry metadata is cached globally. Returning raw dicts or lists from that cache would let callers mutate shared package state through the metadata - objects returned by :func:`get_dataset_info`. + objects returned by [get_dataset_info][atomref.registry.get_dataset_info]. """ if isinstance(value, dict): @@ -256,10 +427,12 @@ def _freeze_json_like(value: object) -> object: def _coerce_finite_float(value: object, *, what: str) -> float: - """Return ``value`` as a finite float or raise :class:`DatasetError`.""" + """Return `value` as a finite float or raise + [DatasetError][atomref.errors.DatasetError]. + """ try: - out = float(value) + out = float(cast(_FloatLike, value)) except (TypeError, ValueError) as exc: raise DatasetError(f"{what} must be a finite float") from exc if not math.isfinite(out): @@ -295,13 +468,35 @@ def _datasets_for_quantity(quantity: QuantityId) -> Mapping[str, object]: def list_quantities() -> tuple[str, ...]: - """List packaged quantity identifiers in registry order.""" + """List packaged quantity identifiers in registry order. + + Returns: + Canonical quantity identifiers in their curated registry order. + + Raises: + DatasetError: If the packaged registry is unavailable or malformed. + """ return tuple(_get_quantities_mapping().keys()) def get_quantity_info(quantity: QuantityId) -> QuantityInfo: - """Return quantity-level metadata for a packaged quantity.""" + """Return quantity-level metadata for a packaged quantity. + + Args: + quantity: Canonical registry quantity identifier. + + Returns: + Immutable [QuantityInfo][atomref.registry.QuantityInfo] for the requested + quantity. + + Raises: + DatasetError: If the quantity is unknown or its metadata is malformed. + + Examples: + >>> get_quantity_info("covalent_radius").units + 'angstrom' + """ raw = _get_quantities_mapping().get(quantity) if not isinstance(raw, dict): @@ -357,8 +552,17 @@ def list_dataset_ids( ) -> tuple[str, ...]: """List packaged dataset identifiers for a quantity. - When ``usage_role`` is provided, only datasets with a matching normalized - role such as ``"target"`` or ``"support"`` are returned. + Args: + quantity: Canonical registry quantity identifier. + usage_role: Optional case-insensitive role filter, such as ``"target"`` + or ``"support"``. `None` includes every role. + + Returns: + Canonical dataset identifiers in curated registry order. + + Raises: + DatasetError: If the quantity is unknown or registry metadata is + malformed. """ dataset_ids = tuple(_datasets_for_quantity(quantity).keys()) @@ -378,7 +582,21 @@ def list_dataset_ids( def list_dataset_infos( quantity: QuantityId, *, usage_role: str | None = None ) -> tuple[DatasetInfo, ...]: - """Return packaged dataset metadata objects for a quantity.""" + """Return packaged dataset metadata objects for a quantity. + + Args: + quantity: Canonical registry quantity identifier. + usage_role: Optional case-insensitive role filter. `None` includes every + role. + + Returns: + Immutable [DatasetInfo][atomref.registry.DatasetInfo] objects in curated + registry order. + + Raises: + DatasetError: If the quantity is unknown or registry metadata is + malformed. + """ return tuple( get_dataset_info(DatasetRef(quantity, set_id)) @@ -387,7 +605,7 @@ def list_dataset_infos( def _coerce_reference(obj: object) -> Reference: - """Coerce a raw registry reference entry into :class:`Reference`.""" + """Coerce a raw registry entry into [Reference][atomref.registry.Reference].""" if not isinstance(obj, dict): raise DatasetError("invalid reference entry in registry.json") @@ -406,7 +624,7 @@ def _coerce_reference(obj: object) -> Reference: def _coerce_coverage(obj: object) -> CoverageInfo | None: - """Coerce raw coverage metadata into :class:`CoverageInfo`.""" + """Coerce raw metadata into [CoverageInfo][atomref.registry.CoverageInfo].""" if not isinstance(obj, dict): return None @@ -425,7 +643,26 @@ def _coerce_coverage(obj: object) -> CoverageInfo | None: def get_dataset_info(ref: DatasetRef) -> DatasetInfo: - """Return curated metadata for a packaged dataset reference.""" + """Return curated metadata for a packaged dataset reference. + + Args: + ref: Quantity and dataset identifier. Dataset aliases are accepted with + Unicode-dash, case, and surrounding-whitespace normalization. + + Returns: + Immutable metadata whose `ref` contains the canonical dataset ID. + + Raises: + DatasetError: If the quantity or dataset is unknown, or registry + metadata is malformed. + + Examples: + >>> info = get_dataset_info( + ... DatasetRef("covalent_radius", "cordero2008") + ... ) + >>> info.units + 'angstrom' + """ actual_set_id = _resolve_set_id(ref.quantity, ref.set_id) actual_ref = DatasetRef(quantity=ref.quantity, set_id=actual_set_id) @@ -475,16 +712,14 @@ def get_dataset_info(ref: DatasetRef) -> DatasetInfo: if isinstance(raw_entry.get("storage"), dict) else None ) + raw_name = raw_entry.get("name") + name = raw_name if isinstance(raw_name, str) else actual_ref.set_id return DatasetInfo( ref=actual_ref, domain=domain, units=units, - name=( - raw_entry.get("name") - if isinstance(raw_entry.get("name"), str) - else actual_ref.set_id - ), + name=name, description=( raw_entry.get("description") if isinstance(raw_entry.get("description"), str) @@ -541,7 +776,9 @@ def _load_csv_columns(filename: str) -> dict[str, tuple[float | None, ...]]: """Load all value columns from one packaged dense-by-Z CSV table.""" path = resources.files("atomref.data").joinpath(filename) - with path.open("r", encoding="utf-8", newline="") as handle: + with io.TextIOWrapper( + path.open("rb"), encoding="utf-8", newline="" + ) as handle: reader = csv.DictReader(handle) if reader.fieldnames is None or "z" not in reader.fieldnames: raise DatasetError(f"invalid CSV file: {filename!r}") @@ -570,13 +807,159 @@ def _load_csv_columns(filename: str) -> dict[str, tuple[float | None, ...]]: @lru_cache(maxsize=None) -def get_builtin_set(ref: DatasetRef) -> ElementScalarSet: - """Load a packaged dataset as an :class:`ElementScalarSet`.""" +def _load_radial_csv_zip( + filename: str, + member: str, + radius_column: str, + density_columns: tuple[tuple[int, str], ...], +) -> tuple[tuple[float, ...], tuple[tuple[float, ...] | None, ...]]: + """Load shared-grid profiles from a single-member ZIP containing CSV.""" + + try: + archive_bytes = _read_package_data_bytes(filename) + with zipfile.ZipFile(io.BytesIO(archive_bytes), mode="r") as archive: + members = archive.infolist() + if len(members) != 1: + raise DatasetError( + f"radial ZIP archive {filename!r} must contain exactly one member" + ) + member_info = members[0] + if member_info.is_dir(): + raise DatasetError( + f"radial ZIP archive {filename!r} contains a directory entry" + ) + unix_mode = (member_info.external_attr >> 16) & 0xFFFF + file_type = stat.S_IFMT(unix_mode) + if ( + member_info.create_system == 3 + and file_type not in (0, stat.S_IFREG) + ): + raise DatasetError( + f"radial ZIP archive {filename!r} member is not a regular file" + ) + if member_info.filename != member: + raise DatasetError( + f"radial ZIP archive {filename!r} does not contain the declared " + f"member {member!r}" + ) + if member_info.flag_bits & 0x1: + raise DatasetError( + f"radial ZIP archive {filename!r} contains an encrypted member" + ) + csv_bytes = archive.read(member_info) + except DatasetError: + raise + except ( + OSError, + RuntimeError, + NotImplementedError, + zipfile.BadZipFile, + zipfile.LargeZipFile, + zlib.error, + ) as exc: + raise DatasetError(f"invalid radial ZIP archive: {filename!r}") from exc + + try: + csv_text = csv_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise DatasetError( + f"invalid UTF-8 CSV member {member!r} in radial ZIP {filename!r}" + ) from exc + + radii: list[float] = [] + profiles: dict[int, list[float]] = {z: [] for z, _ in density_columns} + expected_columns = (radius_column, *(name for _, name in density_columns)) + try: + with io.StringIO(csv_text, newline="") as text_handle: + reader = csv.DictReader(text_handle) + if ( + reader.fieldnames is None + or tuple(reader.fieldnames) != expected_columns + ): + raise DatasetError( + f"invalid radial CSV columns in {member!r} from {filename!r}" + ) + for row_number, row in enumerate(reader, start=2): + radii.append( + _coerce_finite_float( + row.get(radius_column), + what=( + f"radius in {filename!r} row {row_number} " + f"column {radius_column!r}" + ), + ) + ) + for z, column in density_columns: + profiles[z].append( + _coerce_finite_float( + row.get(column), + what=( + f"profile value in {filename!r} row {row_number} " + f"column {column!r}" + ), + ) + ) + except DatasetError: + raise + except (csv.Error, ValueError) as exc: + raise DatasetError( + f"invalid radial CSV member {member!r} in {filename!r}" + ) from exc + + n_z = max(elem.z for elem in iter_elements()) + profiles_by_z: list[tuple[float, ...] | None] = [None] * (n_z + 1) + for z, values in profiles.items(): + if z <= 0 or z > n_z: + raise DatasetError(f"invalid atomic number in {filename!r}: {z}") + profiles_by_z[z] = tuple(values) + return tuple(radii), tuple(profiles_by_z) + + +def _read_package_data_bytes(filename: str) -> bytes: + """Read a packaged data resource without requiring a filesystem path.""" + + path = resources.files("atomref.data").joinpath(filename) + with path.open("rb") as handle: + return handle.read() + + +def _radial_density_columns(info: DatasetInfo) -> tuple[tuple[int, str], ...]: + """Return expected ``(Z, column)`` pairs from radial storage metadata.""" + + if not isinstance(info.storage, Mapping) or info.coverage is None: + raise DatasetError(f"invalid radial storage metadata for dataset: {info.ref!r}") + pattern = info.storage.get("density_column_pattern") + if not isinstance(pattern, str) or "{z" not in pattern: + raise DatasetError(f"invalid radial storage metadata for dataset: {info.ref!r}") + + coverage = info.coverage + if coverage.covered_z: + covered_z = coverage.covered_z + elif coverage.z_min is not None and coverage.z_max is not None: + covered_z = tuple(range(coverage.z_min, coverage.z_max + 1)) + else: + raise DatasetError( + f"invalid radial coverage metadata for dataset: {info.ref!r}" + ) + if len(covered_z) != coverage.n_values: + raise DatasetError( + f"invalid radial coverage metadata for dataset: {info.ref!r}" + ) + + try: + return tuple((z, pattern.format(z=z)) for z in covered_z) + except (KeyError, ValueError) as exc: + raise DatasetError( + f"invalid radial density-column pattern for dataset: {info.ref!r}" + ) from exc + + +def _load_element_scalar_set(info: DatasetInfo) -> ElementScalarSet: + """Load one dense-by-Z scalar CSV dataset.""" - info = get_dataset_info(ref) if info.domain != "element": raise DatasetError( - f"only element-domain datasets are currently supported: {info.ref!r}" + f"element scalar storage requires an element domain: {info.ref!r}" ) if not isinstance(info.storage, Mapping): raise DatasetError(f"missing storage metadata for dataset: {info.ref!r}") @@ -593,12 +976,106 @@ def get_builtin_set(ref: DatasetRef) -> ElementScalarSet: return ElementScalarSet(ref=info.ref, info=info, values_by_z=table[column]) -def resolve_dataset_like(dataset: DatasetLike) -> ElementScalarSet: - """Resolve either a packaged reference or a custom set to a loaded set.""" +def _load_element_radial_set(info: DatasetInfo) -> ElementRadialSet: + """Load one shared-grid radial CSV from a single-member ZIP archive.""" + + if info.domain != "element": + raise DatasetError( + f"element radial storage requires an element domain: {info.ref!r}" + ) + if not isinstance(info.storage, Mapping): + raise DatasetError(f"missing storage metadata for dataset: {info.ref!r}") + filename = info.storage.get("filename") + member = info.storage.get("member") + radius_column = info.storage.get("radius_column") + if ( + not isinstance(filename, str) + or not isinstance(member, str) + or not member + or not isinstance(radius_column, str) + ): + raise DatasetError(f"invalid radial storage metadata for dataset: {info.ref!r}") + + density_columns = _radial_density_columns(info) + radii, profiles_by_z = _load_radial_csv_zip( + filename, + member, + radius_column, + density_columns, + ) + return ElementRadialSet( + ref=info.ref, + info=info, + radii=radii, + profiles_by_z=profiles_by_z, + ) + + +@lru_cache(maxsize=None) +def _load_builtin_set(ref: DatasetRef) -> BuiltinSet: + """Load a canonical packaged dataset by its declared storage kind.""" + + info = get_dataset_info(ref) + if not isinstance(info.storage, Mapping): + raise DatasetError(f"missing storage metadata for dataset: {info.ref!r}") + storage_kind = info.storage.get("kind") + if storage_kind == "element_scalar_csv": + return _load_element_scalar_set(info) + if storage_kind == "element_radial_csv_zip": + return _load_element_radial_set(info) + raise DatasetError( + f"unknown storage kind {storage_kind!r} for dataset: {info.ref!r}" + ) + + +def get_builtin_set(ref: DatasetRef) -> BuiltinSet: + """Load a scalar or radial packaged dataset through the shared registry. + + Args: + ref: Quantity and packaged dataset identifier or alias. + + Returns: + A cached immutable [ElementScalarSet][atomref.registry.ElementScalarSet] + or [ElementRadialSet][atomref.registry.ElementRadialSet], chosen from the + dataset's declared storage kind. + + Raises: + DatasetError: If the reference is unknown, storage metadata is invalid, + or the packaged payload fails validation. + + Examples: + >>> loaded = get_builtin_set( + ... DatasetRef("covalent_radius", "cordero2008") + ... ) + >>> isinstance(loaded, ElementScalarSet) + True + + Notes: + Scalar policies narrow this union internally. Radial profiles never + participate in substitution or linear-transfer policy behavior. + """ + + canonical_ref = get_dataset_info(ref).ref + return _load_builtin_set(canonical_ref) + + +def resolve_scalar_dataset_like( + dataset: ScalarDatasetLike | ElementRadialSet, +) -> ElementScalarSet: + """Resolve an internal scalar source and reject radial payloads.""" if isinstance(dataset, ElementScalarSet): return dataset - return get_builtin_set(dataset) + if isinstance(dataset, ElementRadialSet): + raise DatasetError( + f"dataset {dataset.ref!r} has a radial payload; scalar dataset required" + ) + loaded = get_builtin_set(dataset) + if not isinstance(loaded, ElementScalarSet): + raise DatasetError( + f"dataset {loaded.ref!r} has a radial payload; scalar dataset required" + ) + return loaded def _is_placeholder_value(info: DatasetInfo, value: float) -> bool: diff --git a/src/atomref/transfer.py b/src/atomref/transfer.py index 9adb0ce..272c9b3 100644 --- a/src/atomref/transfer.py +++ b/src/atomref/transfer.py @@ -3,10 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Literal, Protocol, TypeGuard, runtime_checkable from .errors import PolicyError -from .registry import DatasetLike +from .registry import ScalarDatasetLike if TYPE_CHECKING: # pragma: no cover - typing only from .policy import ValuePolicy @@ -19,7 +19,7 @@ "transfer_linear", "fallback", ] -"""Source labels that may be admitted into nested linear-transfer workflows.""" +"""Source labels admitted into nested linear-transfer workflows.""" _ALLOWED_TRANSFER_VALUE_SOURCES = frozenset( { @@ -43,12 +43,28 @@ ) +def _is_transfer_value_source(source: str) -> TypeGuard[TransferValueSource]: + """Return whether ``source`` is an admitted nested-result label.""" + + return source in _ALLOWED_TRANSFER_VALUE_SOURCES + + @runtime_checkable class SupportsValuePolicy(Protocol): - """Protocol for wrapper objects that can expose a generic value policy.""" + """Protocol for wrappers that expose a generic scalar value policy. + + Notes: + [RadiiPolicy][atomref.RadiiPolicy] and [XHPolicy][atomref.XHPolicy] + implement this structural protocol. Custom wrappers need not inherit + from it; providing a compatible `as_value_policy()` method is enough. + """ def as_value_policy(self) -> "ValuePolicy[str]": - """Return the generic element-domain value policy.""" + """Return the generic element-domain value policy. + + Returns: + A [ValuePolicy][atomref.ValuePolicy] over canonical element symbols. + """ @dataclass(frozen=True, slots=True) @@ -56,8 +72,17 @@ class LinearFit: """Summary statistics for a fitted linear transfer model. Parameters are stored in a compact, serializable form so they can be - attached to :class:`atomref.policy.LookupResult` objects and reused in + attached to [LookupResult][atomref.LookupResult] objects and reused in reporting code. + + Attributes: + coefficients: Fitted slopes, one per predictor. The current runtime + uses exactly one predictor. Units are target units divided by the + corresponding predictor units. + intercept: Fitted intercept in target-dataset units. + n_points: Number of overlapping element values used in the fit. + r2: Dimensionless coefficient of determination. + rmse: Root-mean-square residual in target-dataset units. """ coefficients: tuple[float, ...] @@ -72,9 +97,24 @@ class SubstitutionTransfer: """Use another dataset or policy directly when the base dataset is missing. The selected value is copied from the source rather than inferred. + + Attributes: + source: Packaged scalar reference, custom + [ElementScalarSet][atomref.registry.ElementScalarSet], generic + [ValuePolicy][atomref.ValuePolicy], or compatible wrapper policy. + + Examples: + >>> from atomref import DatasetRef, SubstitutionTransfer + >>> transfer = SubstitutionTransfer( + ... source=DatasetRef("covalent_radius", "csd_legacy_cov") + ... ) + + Notes: + Source and target values must use compatible units. The policy engine + does not perform dimensional conversion. """ - source: DatasetLike | SupportsValuePolicy | ValuePolicy[str] + source: ScalarDatasetLike | SupportsValuePolicy | ValuePolicy[str] @dataclass(frozen=True, slots=True) @@ -85,18 +125,42 @@ class LinearTransfer: for forward compatibility, but the runtime intentionally accepts exactly one predictor source. - For nested policy predictors, two safeguards apply: - - - ``fit_sources`` / ``fit_max_depth`` control which predictor values may be - used when fitting the linear model itself; - - ``prediction_sources`` / ``prediction_max_depth`` control which nested - predictor values may be used for the final requested element. - - The defaults are intentionally conservative for fitting and permissive only - enough to allow one additional completion step at prediction time. + Attributes: + predictors: Predictor sources. The tuple must be nonempty, and the + current resolver supports exactly one predictor at evaluation time. + min_points: Minimum overlapping fit values. Must be at least 2 and + defaults to 2. + exclude_placeholders: Whether declared placeholder values are excluded + from fitting. Defaults to `True`. + fit_sources: Nested predictor result sources admitted to fitting. + Defaults to direct ``"base"`` and ``"override"`` values. + prediction_sources: Nested result sources admitted when predicting the + requested element. Defaults to base, override, substitution, and + linear-transfer values. + fit_max_depth: Maximum nested transfer depth admitted to fitting. + Defaults to 0 and must be nonnegative. + prediction_max_depth: Maximum nested transfer depth admitted for the + requested prediction. Defaults to 1 and must be nonnegative. + + Raises: + PolicyError: If predictors are empty, `min_points` is below 2, a source + control is empty or unknown, or either depth limit is negative. + + Examples: + >>> from atomref import DatasetRef, LinearTransfer + >>> transfer = LinearTransfer( + ... predictors=(DatasetRef("atomic_radius", "rahm2016"),) + ... ) + + Notes: + Fit controls and prediction controls are independent. Predictor and + target units must be internally consistent; no unit conversion is + performed. """ - predictors: tuple[DatasetLike | SupportsValuePolicy | ValuePolicy[str], ...] + predictors: tuple[ + ScalarDatasetLike | SupportsValuePolicy | ValuePolicy[str], ... + ] min_points: int = 2 exclude_placeholders: bool = True fit_sources: tuple[TransferValueSource, ...] = _DEFAULT_LINEAR_FIT_SOURCES @@ -156,7 +220,7 @@ def _normalize_transfer_value_sources( normalized: list[TransferValueSource] = [] seen: set[str] = set() for source in sources: - if source not in _ALLOWED_TRANSFER_VALUE_SOURCES: + if not _is_transfer_value_source(source): allowed = ", ".join(sorted(_ALLOWED_TRANSFER_VALUE_SOURCES)) raise PolicyError( f"LinearTransfer {field_name} contains unsupported source " diff --git a/src/atomref/xh.py b/src/atomref/xh.py index 5018d99..14d9980 100644 --- a/src/atomref/xh.py +++ b/src/atomref/xh.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field import math +from typing import SupportsFloat, SupportsIndex, cast from .elements import canonicalize_element_symbol, is_valid_element_symbol from .errors import PolicyError @@ -18,25 +19,43 @@ DatasetInfo, DatasetRef, ElementScalarSet, - get_builtin_set, get_dataset_info, list_dataset_ids, list_dataset_infos, + resolve_scalar_dataset_like, ) from .transfer import LinearTransfer, TransferModel XHSet = ElementScalarSet +"""Typing alias for an immutable parent-element X-H bond-length dataset.""" _QUANTITY = "xh_bond_length" +_FloatLike = str | bytes | bytearray | memoryview | SupportsFloat | SupportsIndex @dataclass(frozen=True, slots=True) class XHPolicy: """Policy wrapper specialized for parent-element X-H bond lengths. - The quantity key is fixed to ``"xh_bond_length"`` and uses the parent - element ``X`` as the lookup key. ``H`` itself is not considered a valid - parent element for this quantity. + Attributes: + base_set: Packaged X-H set ID or custom + [ElementScalarSet][atomref.registry.ElementScalarSet]. + transfers: Ordered substitution or linear-transfer rules. Defaults to + no transfers. + overrides: Explicit finite, nonnegative parent-element values checked + before the base set. Defaults to an empty mapping. + fallback: Final finite, nonnegative value, or `None`. Defaults to `None`. + + Examples: + >>> policy = XHPolicy(base_set="csd_legacy_xh_cno") + >>> get_xh_bond_length("C", policy=policy) + 1.089 + + Notes: + The quantity key is fixed to ``"xh_bond_length"`` and uses parent + element X as its lookup key. H, D, and T are not valid parent elements. + Packaged values are in angstrom. Custom sources and policy values must + use compatible units because policies perform no unit conversion. """ base_set: str | XHSet @@ -45,8 +64,19 @@ class XHPolicy: fallback: float | None = None def as_value_policy(self) -> ValuePolicy[str]: - """Convert the X-H policy into the generic scalar-value policy.""" + """Convert this wrapper into the generic scalar policy. + Returns: + An element-domain [ValuePolicy][atomref.policy.ValuePolicy] with + hydrogen blocked as a parent. + + Raises: + DatasetError: If a packaged set is unknown or non-scalar. + PolicyError: If the base quantity, override, or fallback is invalid, + or if H/D/T is used as an override parent. + """ + + base: DatasetRef | ElementScalarSet if isinstance(self.base_set, ElementScalarSet): if self.base_set.ref.quantity != _QUANTITY: raise PolicyError( @@ -89,7 +119,7 @@ def _coerce_non_negative_xh_value(value: object, *, what: str) -> float: """Validate an X-H-like policy number.""" try: - out = float(value) + out = float(cast(_FloatLike, value)) except (TypeError, ValueError) as exc: raise PolicyError(f"{what} must be a finite float") from exc if not math.isfinite(out): @@ -109,27 +139,69 @@ def _normalize_xh_symbol(symbol: str | None) -> str | None: def list_xh_sets(*, usage_role: str | None = None) -> tuple[str, ...]: - """List packaged X-H set ids.""" + """List packaged X-H set IDs. + + Args: + usage_role: Optional case-insensitive metadata-role filter. + + Returns: + Canonical set IDs in curated registry order. + + Raises: + DatasetError: If registry metadata is malformed. + """ return list_dataset_ids(_QUANTITY, usage_role=usage_role) def list_xh_set_infos(*, usage_role: str | None = None) -> tuple[DatasetInfo, ...]: - """Return packaged metadata objects for X-H sets.""" + """Return packaged metadata objects for X-H sets. + + Args: + usage_role: Optional case-insensitive metadata-role filter. + + Returns: + Immutable [DatasetInfo][atomref.registry.DatasetInfo] objects in curated + registry order. + + Raises: + DatasetError: If registry metadata is malformed. + """ return list_dataset_infos(_QUANTITY, usage_role=usage_role) def get_xh_set_info(set_id: str) -> DatasetInfo: - """Return metadata for one packaged X-H set.""" + """Return metadata for one packaged X-H set. + + Args: + set_id: Canonical packaged set ID or accepted alias. + + Returns: + Curated metadata, including angstrom units and provenance. + + Raises: + DatasetError: If the set is unknown or metadata is malformed. + """ return get_dataset_info(DatasetRef(_QUANTITY, set_id)) def get_xh_set(set_id: str) -> XHSet: - """Load one packaged X-H set as an :class:`ElementScalarSet`.""" + """Load one packaged X-H set as an + [ElementScalarSet][atomref.registry.ElementScalarSet]. - return get_builtin_set(DatasetRef(_QUANTITY, set_id)) + Args: + set_id: Canonical packaged set ID or accepted alias. + + Returns: + A cached immutable parent-element set in angstrom. + + Raises: + DatasetError: If the set is unknown, malformed, or non-scalar. + """ + + return resolve_scalar_dataset_like(DatasetRef(_QUANTITY, set_id)) def lookup_xh_bond_length( @@ -137,7 +209,26 @@ def lookup_xh_bond_length( *, policy: XHPolicy | None = None, ) -> LookupResult: - """Resolve a parent-element X-H bond length with provenance.""" + """Resolve a parent-element X-H bond length with provenance. + + Args: + symbol: Parent-element token, or `None`. H/D/T are explicitly blocked. + policy: X-H policy; `None` selects + [DEFAULT_XH_POLICY][atomref.DEFAULT_XH_POLICY]. + + Returns: + Lookup result whose value is in angstrom, or an explicit missing result. + A blocked hydrogen parent includes an explanatory note. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If policy or transfer configuration is invalid. + + Examples: + >>> result = lookup_xh_bond_length("C") + >>> result.value, result.source + (1.089, 'base') + """ active = DEFAULT_XH_POLICY if policy is None else policy lookup = _lookup_value_from_policy_source(symbol, source=active) @@ -156,7 +247,20 @@ def get_xh_bond_length( *, policy: XHPolicy | None = None, ) -> float | None: - """Return only the selected X-H bond-length value, without provenance.""" + """Return only the selected parent-element X-H bond length. + + Args: + symbol: Parent-element token, or `None`. H/D/T are explicitly blocked. + policy: X-H policy; `None` selects + [DEFAULT_XH_POLICY][atomref.DEFAULT_XH_POLICY]. + + Returns: + Selected bond length in angstrom, or `None` when resolution is missing. + + Raises: + DatasetError: If a referenced dataset is unknown or non-scalar. + PolicyError: If policy or transfer configuration is invalid. + """ active = DEFAULT_XH_POLICY if policy is None else policy return _get_value_from_policy_source(symbol, source=active) diff --git a/tests/meta/test_imports.py b/tests/meta/test_imports.py index 66210e7..e3bca9a 100644 --- a/tests/meta/test_imports.py +++ b/tests/meta/test_imports.py @@ -9,6 +9,7 @@ 'atomref.registry', 'atomref.transfer', 'atomref.policy', + 'atomref.proatoms', 'atomref.radii', 'atomref.xh', ] diff --git a/tests/meta/test_notebook_tool.py b/tests/meta/test_notebook_tool.py new file mode 100644 index 0000000..3ef9ff0 --- /dev/null +++ b/tests/meta/test_notebook_tool.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import signal +import subprocess +import sys + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHECK_NOTEBOOKS_PATH = REPO_ROOT / "tools" / "check_notebooks.py" +CI_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "ci.yml" + +spec = importlib.util.spec_from_file_location( + "check_notebooks_tool", CHECK_NOTEBOOKS_PATH +) +assert spec is not None and spec.loader is not None +check_notebooks = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = check_notebooks +spec.loader.exec_module(check_notebooks) + + +class FakeProcess: + def __init__(self, returncode: int = 0) -> None: + self.pid = 4312 + self.returncode = returncode + self.wait_timeouts: list[float] = [] + self.killed = False + + def poll(self) -> int | None: + return None + + def wait(self, timeout: float) -> int: + self.wait_timeouts.append(timeout) + return self.returncode + + def kill(self) -> None: + self.killed = True + + +def test_worker_process_group_options_are_platform_specific( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", False) + assert check_notebooks._worker_group_options() == {"start_new_session": True} + + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", True) + monkeypatch.setattr( + check_notebooks.subprocess, + "CREATE_NEW_PROCESS_GROUP", + 0x00000200, + raising=False, + ) + assert check_notebooks._worker_group_options() == {"creationflags": 0x00000200} + + +def test_worker_uses_one_bounded_standard_jupyter_process( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + notebook = tmp_path / "example.ipynb" + notebook.touch() + working_dir = tmp_path / "working" + working_dir.mkdir() + runtime_root = tmp_path / "runtime" / "example" + process = FakeProcess() + captured: dict[str, object] = {} + + def popen(command: list[str], **kwargs: object) -> FakeProcess: + captured["command"] = command + captured.update(kwargs) + return process + + monkeypatch.setattr(check_notebooks.subprocess, "Popen", popen) + monkeypatch.setattr( + check_notebooks, + "_worker_group_options", + lambda: {"start_new_session": True}, + ) + + check_notebooks._run_worker( + notebook, + working_dir=working_dir, + runtime_root=runtime_root, + ) + + assert captured["command"] == [ + sys.executable, + "-m", + "jupyter", + "execute", + f"--timeout={check_notebooks.CELL_TIMEOUT_SECONDS}", + f"--startup_timeout={check_notebooks.KERNEL_STARTUP_TIMEOUT_SECONDS}", + "--Application.log_level=INFO", + "--inplace", + str(notebook), + ] + assert captured["cwd"] == working_dir + assert captured["start_new_session"] is True + environment = captured["env"] + assert isinstance(environment, dict) + assert environment["JUPYTER_RUNTIME_DIR"] == str(runtime_root / "jupyter-runtime") + assert environment["MPLBACKEND"] == "Agg" + assert process.wait_timeouts == [check_notebooks.WORKER_TIMEOUT_SECONDS] + + +def test_worker_timeout_terminates_group_and_names_phase( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + notebook = tmp_path / "cleanup-hang.ipynb" + notebook.touch() + process = FakeProcess() + terminated: list[FakeProcess] = [] + + def timeout(*, timeout: float) -> int: + raise subprocess.TimeoutExpired(["jupyter", "execute"], timeout) + + process.wait = timeout # type: ignore[method-assign] + monkeypatch.setattr( + check_notebooks.subprocess, + "Popen", + lambda *_args, **_kwargs: process, + ) + monkeypatch.setattr(check_notebooks, "_terminate_worker", terminated.append) + + with pytest.raises( + check_notebooks.NotebookCheckError, + match=( + r"cleanup-hang\.ipynb.*420-second.*kernel startup, cell execution, " + r"kernel cleanup, or process exit.*worker containment completed" + ), + ): + check_notebooks._run_worker( + notebook, + working_dir=tmp_path, + runtime_root=tmp_path / "runtime", + ) + + assert terminated == [process] + + +def test_worker_nonzero_exit_names_notebook_phase_and_status( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + notebook = tmp_path / "cell-error.ipynb" + notebook.touch() + process = FakeProcess(returncode=17) + monkeypatch.setattr( + check_notebooks.subprocess, + "Popen", + lambda *_args, **_kwargs: process, + ) + + with pytest.raises( + check_notebooks.NotebookCheckError, + match=r"cell-error\.ipynb.*startup, execution, or cleanup.*status 17", + ): + check_notebooks._run_worker( + notebook, + working_dir=tmp_path, + runtime_root=tmp_path / "runtime", + ) + + +def test_posix_worker_group_is_force_killed_and_reaped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = FakeProcess() + signals: list[tuple[int, signal.Signals]] = [] + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", False) + monkeypatch.setattr( + check_notebooks.os, + "killpg", + lambda pid, sent_signal: signals.append((pid, sent_signal)), + ) + + check_notebooks._terminate_worker(process) + + assert signals == [(process.pid, signal.SIGKILL)] + assert process.wait_timeouts == [ + check_notebooks.WORKER_TERMINATION_TIMEOUT_SECONDS + ] + assert not process.killed + + +def test_windows_worker_tree_is_force_killed_and_reaped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = FakeProcess() + calls: list[tuple[list[str], dict[str, object]]] = [] + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", True) + + def run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + calls.append((command, kwargs)) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(check_notebooks.subprocess, "run", run) + + check_notebooks._terminate_worker(process) + + assert calls[0][0] == [ + "taskkill", + "/PID", + str(process.pid), + "/T", + "/F", + ] + assert calls[0][1]["timeout"] == ( + check_notebooks.WORKER_TERMINATION_TIMEOUT_SECONDS + ) + assert process.wait_timeouts == [ + check_notebooks.WORKER_TERMINATION_TIMEOUT_SECONDS + ] + + +def test_posix_already_exited_group_is_still_reaped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = FakeProcess() + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", False) + + def missing_group(_pid: int, _sent_signal: signal.Signals) -> None: + raise ProcessLookupError + + monkeypatch.setattr(check_notebooks.os, "killpg", missing_group) + + check_notebooks._terminate_worker(process) + + assert process.wait_timeouts == [ + check_notebooks.WORKER_TERMINATION_TIMEOUT_SECONDS + ] + + +@pytest.mark.parametrize( + "taskkill_error", + [OSError("taskkill unavailable"), subprocess.TimeoutExpired("taskkill", 10)], +) +def test_windows_taskkill_failure_is_reported_after_direct_kill( + taskkill_error: BaseException, + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = FakeProcess() + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", True) + + def fail(*_args: object, **_kwargs: object) -> None: + raise taskkill_error + + monkeypatch.setattr(check_notebooks.subprocess, "run", fail) + + with pytest.raises( + check_notebooks.NotebookCheckError, + match=r"could not confirm termination of Windows worker tree 4312", + ): + check_notebooks._terminate_worker(process) + + assert process.killed + + +def test_unreapable_worker_has_bounded_diagnostic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = FakeProcess() + wait_calls = 0 + monkeypatch.setattr(check_notebooks, "IS_WINDOWS", False) + monkeypatch.setattr(check_notebooks.os, "killpg", lambda *_args: None) + + def timeout(*, timeout: float) -> int: + nonlocal wait_calls + wait_calls += 1 + raise subprocess.TimeoutExpired("worker", timeout) + + process.wait = timeout # type: ignore[method-assign] + + with pytest.raises( + check_notebooks.NotebookCheckError, + match=r"worker process 4312 could not be reaped", + ): + check_notebooks._terminate_worker(process) + + assert wait_calls == 2 + assert process.killed + + +def test_aggregate_success_waits_for_every_worker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + source_root = tmp_path / "source" + source_root.mkdir() + notebooks = [] + for name in ("one.ipynb", "two.ipynb"): + notebook = tmp_path / name + notebook.touch() + notebooks.append(notebook) + completed: list[str] = [] + + monkeypatch.setattr(check_notebooks, "SRC", source_root) + monkeypatch.setattr( + check_notebooks, + "_run_worker", + lambda path, **_kwargs: completed.append(path.name), + ) + + assert check_notebooks.smoke_execute(notebooks) == 0 + + assert completed == ["one.ipynb", "two.ipynb"] + output = capsys.readouterr().out + assert "phase=Jupyter startup/execution/cleanup" in output + assert output.rstrip().endswith( + "Smoke-executed 2 notebook(s) in temporary kernels." + ) + + +def test_cleanup_hang_cannot_print_aggregate_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + source_root = tmp_path / "source" + source_root.mkdir() + notebook = tmp_path / "cleanup-hang.ipynb" + notebook.touch() + monkeypatch.setattr(check_notebooks, "SRC", source_root) + + def fail(*_args: object, **_kwargs: object) -> None: + raise check_notebooks.NotebookCheckError("cleanup did not exit") + + monkeypatch.setattr(check_notebooks, "_run_worker", fail) + + with pytest.raises(check_notebooks.NotebookCheckError): + check_notebooks.smoke_execute([notebook]) + + output = capsys.readouterr().out + assert "cleanup-hang.ipynb" in output + assert "Smoke-executed" not in output + + +def test_complete_check_deadline_stops_before_another_worker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_root = tmp_path / "source" + source_root.mkdir() + notebook = tmp_path / "not-started.ipynb" + notebook.touch() + monotonic_values = iter([10.0, 10.0 + check_notebooks.CHECK_TIMEOUT_SECONDS]) + monkeypatch.setattr(check_notebooks, "SRC", source_root) + monkeypatch.setattr( + check_notebooks.time, + "monotonic", + lambda: next(monotonic_values), + ) + monkeypatch.setattr( + check_notebooks, + "_run_worker", + lambda *_args, **_kwargs: pytest.fail("expired worker must not start"), + ) + + with pytest.raises( + check_notebooks.NotebookCheckError, + match=r"not-started\.ipynb.*complete notebook smoke check exceeded.*900", + ): + check_notebooks.smoke_execute([notebook]) + + +def test_ci_notebook_job_has_outer_timeout() -> None: + workflow = CI_WORKFLOW_PATH.read_text(encoding="utf-8") + notebook_job = workflow.split(" notebooks-smoke:", 1)[1].split( + "\n docs-check:", 1 + )[0] + smoke_step = notebook_job.split( + " - name: Smoke-execute notebooks with Jupyter", 1 + )[1] + + assert "timeout-minutes: 20" in smoke_step + assert "run: python tools/check_notebooks.py" in smoke_step diff --git a/tests/meta/test_notebooks.py b/tests/meta/test_notebooks.py index d420476..f5c1777 100644 --- a/tests/meta/test_notebooks.py +++ b/tests/meta/test_notebooks.py @@ -1,41 +1,140 @@ from __future__ import annotations +import base64 +import json from pathlib import Path -import subprocess -import sys REPO_ROOT = Path(__file__).resolve().parents[2] -CHECK_SCRIPT = REPO_ROOT / "tools" / "check_notebooks.py" -EXPORT_SCRIPT = REPO_ROOT / "tools" / "export_notebooks.py" -NOTEBOOKS = REPO_ROOT / "notebooks" -EXPORTED_NOTEBOOKS = REPO_ROOT / "docs" / "notebooks" - - -def test_notebook_files_exist() -> None: - expected = { - "01-quickstart.ipynb", - "02-policies-and-assessment.ipynb", - "03-custom-sets-and-discovery.ipynb", - } - actual = {path.name for path in NOTEBOOKS.glob("*.ipynb")} - assert expected.issubset(actual) - - -def test_notebooks_validate_and_execute() -> None: - subprocess.run([sys.executable, str(CHECK_SCRIPT)], cwd=REPO_ROOT, check=True) - - -def test_exported_notebook_pages_are_in_sync() -> None: - expected = { - "01-quickstart.md", - "02-policies-and-assessment.md", - "03-custom-sets-and-discovery.md", - } - actual = {path.name for path in EXPORTED_NOTEBOOKS.glob("*.md")} - assert expected.issubset(actual) - subprocess.run( - [sys.executable, str(EXPORT_SCRIPT), "--check"], - cwd=REPO_ROOT, - check=True, +NOTEBOOKS = REPO_ROOT / "docs" / "notebooks" +EXPECTED_NOTEBOOKS = { + "01-quickstart.ipynb", + "02-policies-and-assessment.ipynb", + "03-custom-sets-and-discovery.ipynb", + "04-ias-method-selection-study.ipynb", + "05-proatomic-density-and-ias.ipynb", +} +EXPECTED_SAVED_PNG_OUTPUTS = { + "01-quickstart.ipynb": 0, + "02-policies-and-assessment.ipynb": 0, + "03-custom-sets-and-discovery.ipynb": 0, + "04-ias-method-selection-study.ipynb": 3, + "05-proatomic-density-and-ias.ipynb": 2, +} +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def _load_notebook(path: Path) -> dict[str, object]: + data = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + assert data.get("nbformat") == 4 + return data + + +def _source(cell: dict[str, object]) -> str: + source = cell.get("source", []) + if isinstance(source, str): + return source + assert isinstance(source, list) + assert all(isinstance(line, str) for line in source) + return "".join(source) + + +def _text(value: object) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + assert isinstance(value, list) + assert all(isinstance(line, str) for line in value) + return "".join(value) + + +def _cells(data: dict[str, object]) -> list[dict[str, object]]: + cells = data.get("cells") + assert isinstance(cells, list) + assert all(isinstance(cell, dict) for cell in cells) + return cells + + +def test_notebooks_have_one_direct_documentation_source() -> None: + assert {path.name for path in NOTEBOOKS.glob("*.ipynb")} == EXPECTED_NOTEBOOKS + assert not list((REPO_ROOT / "notebooks").glob("*.ipynb")) + assert not list(NOTEBOOKS.glob("*.md")) + assert not (REPO_ROOT / "tools" / "export_notebooks.py").exists() + + +def test_notebooks_have_narrative_structure_and_saved_success() -> None: + for name in sorted(EXPECTED_NOTEBOOKS): + cells = _cells(_load_notebook(NOTEBOOKS / name)) + markdown_cells = [cell for cell in cells if cell.get("cell_type") == "markdown"] + code_cells = [cell for cell in cells if cell.get("cell_type") == "code"] + markdown = "\n".join(_source(cell) for cell in markdown_cells) + + assert markdown.lstrip().startswith("# "), name + assert "prerequisite" in markdown.lower(), name + assert "## " in markdown, name + assert "limitation" in markdown.lower(), name + assert code_cells, name + + outputs = [ + output + for cell in code_cells + for output in cell.get("outputs", []) + if isinstance(output, dict) + ] + assert outputs, name + assert not any(output.get("output_type") == "error" for output in outputs) + + text_outputs = [] + png_outputs = [] + for output in outputs: + data = output.get("data", {}) + assert isinstance(data, dict) + if _text(output.get("text")) or _text(data.get("text/plain")): + text_outputs.append(output) + if _text(data.get("image/png")): + png_outputs.append(output) + + assert text_outputs, name + assert len(png_outputs) == EXPECTED_SAVED_PNG_OUTPUTS[name] + for output in png_outputs: + data = output["data"] + assert isinstance(data, dict) + payload = base64.b64decode(_text(data["image/png"]), validate=True) + assert payload.startswith(PNG_SIGNATURE), name + + for index, cell in enumerate(cells): + if cell.get("cell_type") != "code" or not _source(cell).strip(): + continue + assert index > 0, name + previous = cells[index - 1] + assert previous.get("cell_type") == "markdown", name + assert _source(previous).strip(), name + + +def test_notebook_site_content_includes_math_text_and_png() -> None: + notebooks = [_load_notebook(NOTEBOOKS / name) for name in EXPECTED_NOTEBOOKS] + cells = [cell for notebook in notebooks for cell in _cells(notebook)] + markdown = "\n".join( + _source(cell) for cell in cells if cell.get("cell_type") == "markdown" + ) + outputs = [ + output + for cell in cells + for output in cell.get("outputs", []) + if isinstance(output, dict) + ] + + assert "$\\rho_c$" in markdown + assert "$$\n\\rho_c" in markdown + assert any( + output.get("output_type") == "stream" + and bool("".join(output.get("text", []))) + for output in outputs + ) + assert any( + isinstance(output.get("data"), dict) + and bool(output["data"].get("image/png")) + for output in outputs ) diff --git a/tests/meta/test_package_data.py b/tests/meta/test_package_data.py index a9a7e61..7581f61 100644 --- a/tests/meta/test_package_data.py +++ b/tests/meta/test_package_data.py @@ -9,6 +9,7 @@ def test_packaged_data_files_are_available() -> None: for name in ( 'periodic_table.csv', 'covalent.csv', + 'proatomic_density_neutral.zip', 'van_der_waals.csv', 'registry.json', 'xh_bond_length.csv', @@ -26,3 +27,26 @@ def test_packaged_registry_keeps_atomic_support_classification() -> None: assert rahm['usage_role'] == 'support' assert rahm['semantic_class'] == 'atomic_isodensity' assert rahm['phase_context'] == 'isolated_atom' + + +def test_packaged_registry_records_proatomic_attribution() -> None: + data_root = resources.files('atomref.data') + raw = json.loads(data_root.joinpath('registry.json').read_text(encoding='utf-8')) + + datasets = raw['datasets']['proatomic_density'] + matches = [ + entry + for entry in datasets.values() + if entry.get('storage', {}).get('filename') + == 'proatomic_density_neutral.zip' + ] + assert len(matches) == 1 + + entry_text = json.dumps(matches[0], sort_keys=True) + for marker in ( + 'CC BY 4.0', + '10.5281/zenodo.21291021', + '10.5281/zenodo.21291022', + 'pbe0_sfx2c_dyallv4z_h-lr_spherical_v2', + ): + assert marker in entry_text diff --git a/tests/meta/test_packaging_metadata.py b/tests/meta/test_packaging_metadata.py new file mode 100644 index 0000000..7017ee8 --- /dev/null +++ b/tests/meta/test_packaging_metadata.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 + import tomli as tomllib + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "pyproject.toml" +EXPECTED_NOTEBOOKS_DEPENDENCIES = [ + "ipykernel>=6.29", + "matplotlib>=3.8", + "mkdocs>=1.6,<2", + "mkdocs-jupyter>=0.26,<0.27", + "nbclient>=0.10,<0.12", + "nbformat>=5.10,<6", +] +COMPONENT_EXTRAS = {"test", "notebooks", "docs", "dev"} + + +def _project_metadata() -> dict[str, object]: + return tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"] + + +def test_runtime_dependencies_remain_empty() -> None: + project = _project_metadata() + + assert project["dependencies"] == [] + + +def test_notebooks_extra_is_complete_and_singular_name_is_removed() -> None: + project = _project_metadata() + extras = project["optional-dependencies"] + + assert extras["notebooks"] == EXPECTED_NOTEBOOKS_DEPENDENCIES + assert "notebook" not in extras + + +def test_all_extra_is_the_exact_union_of_component_extras() -> None: + project = _project_metadata() + extras = project["optional-dependencies"] + + expected_all = set().union( + *(set(extras[extra]) for extra in COMPONENT_EXTRAS) + ) + assert set(extras["all"]) == expected_all + assert len(extras["all"]) == len(set(extras["all"])) + + +def test_all_extra_includes_contributor_tooling() -> None: + project = _project_metadata() + all_requirements = "\n".join(project["optional-dependencies"]["all"]).lower() + + for contributor_package in ( + "build", + "cffconvert", + "flake8", + "mypy", + "pytest", + "twine", + ): + assert contributor_package in all_requirements + + +def test_dev_extra_contains_release_validation_tools() -> None: + project = _project_metadata() + dev_requirements = "\n".join(project["optional-dependencies"]["dev"]).lower() + + assert "mypy" in dev_requirements + assert "cffconvert" in dev_requirements + + +def test_mypy_uses_strict_minimum_python_configuration() -> None: + config = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["tool"]["mypy"] + + assert config == {"python_version": "3.10", "strict": True} + + +def test_docs_extra_contains_only_used_documentation_tooling() -> None: + project = _project_metadata() + docs_requirements = "\n".join(project["optional-dependencies"]["docs"]) + + assert "mkdocs-include-markdown-plugin" not in docs_requirements + assert "tomli" not in docs_requirements + + +def test_mkdocs_stays_on_the_supported_1_x_line() -> None: + project = _project_metadata() + extras = project["optional-dependencies"] + + for extra in ("docs", "notebooks", "all"): + assert extras[extra].count("mkdocs>=1.6,<2") == 1 + + +def test_python_314_is_classified() -> None: + project = _project_metadata() + + assert "Programming Language :: Python :: 3.14" in project["classifiers"] diff --git a/tests/meta/test_public_api.py b/tests/meta/test_public_api.py index f3583a1..97138de 100644 --- a/tests/meta/test_public_api.py +++ b/tests/meta/test_public_api.py @@ -1,22 +1,51 @@ from __future__ import annotations +import inspect +from pathlib import Path + import atomref as ar -REQUIRED_PUBLIC_NAMES = { +REPO_ROOT = Path(__file__).resolve().parents[2] + + +EXPECTED_PUBLIC_NAMES = { + '__version__', + 'BuiltinSet', 'Element', 'DatasetRef', 'DatasetInfo', + 'CoverageInfo', + 'ElementRadialSet', 'ElementScalarSet', 'QuantityInfo', + 'Reference', 'LookupResult', + 'ValuePolicy', 'RadiiPolicy', + 'RadiiElementAssessment', + 'RadiiPolicyAssessment', 'DEFAULT_COVALENT_POLICY', 'DEFAULT_VDW_POLICY', + 'BOHR_TO_ANGSTROM', + 'DEFAULT_PROATOMIC_DENSITY_SET', + 'PROATOMIC_TAIL_CUTOFF', + 'IAS_MINIMUM_RESOLUTION_BOHR', + 'IASPositionResult', + 'ProatomicDensityProfile', + 'ProatomicDensitySet', + 'LinearFit', 'LinearTransfer', 'SubstitutionTransfer', + 'canonicalize_element_symbol', + 'get_element', + 'iter_elements', + 'is_valid_element_symbol', 'get_builtin_set', + 'get_dataset_info', + 'get_quantity_info', 'get_radii_set', + 'get_radii_set_info', 'get_covalent_radius', 'lookup_covalent_radius', 'get_vdw_radius', @@ -24,6 +53,7 @@ 'XHPolicy', 'DEFAULT_XH_POLICY', 'get_xh_set', + 'get_xh_set_info', 'get_xh_bond_length', 'lookup_xh_bond_length', 'list_xh_sets', @@ -33,6 +63,18 @@ 'list_dataset_infos', 'list_radii_sets', 'list_radii_set_infos', + 'get_proatomic_density', + 'get_proatomic_density_profile', + 'get_proatomic_density_set', + 'get_proatomic_density_set_info', + 'estimate_proatomic_boundary', + 'estimate_promolecular_density_minimum', + 'estimate_ias_position', + 'list_proatomic_density_sets', + 'list_proatomic_density_set_infos', + 'lookup_value', + 'get_value', + 'assess_radii_policy', } @@ -41,5 +83,23 @@ def test___all___exports_existing_objects() -> None: assert hasattr(ar, name), name -def test_core_public_api_names_are_exported() -> None: - assert REQUIRED_PUBLIC_NAMES.issubset(set(ar.__all__)) +def test_public_api_is_exact() -> None: + assert len(ar.__all__) == len(set(ar.__all__)) + assert set(ar.__all__) == EXPECTED_PUBLIC_NAMES + + +def test_docs_merge_init_signatures_into_public_classes() -> None: + config = (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8") + assert "merge_init_into_class: true" in config + + +def test_lookup_value_examples_are_a_top_level_docstring_section() -> None: + docstring = inspect.getdoc(ar.lookup_value) + assert docstring is not None + lines = docstring.splitlines() + + raises_index = lines.index("Raises:") + examples_index = lines.index("Examples:") + notes_index = lines.index("Notes:") + + assert raises_index < examples_index < notes_index diff --git a/tests/meta/test_readme_sync.py b/tests/meta/test_readme_sync.py index fe56ac2..21e965e 100644 --- a/tests/meta/test_readme_sync.py +++ b/tests/meta/test_readme_sync.py @@ -17,4 +17,32 @@ def test_readme_is_in_sync(tmp_path: Path) -> None: cwd=REPO_ROOT, check=True, ) - assert generated.read_text(encoding='utf-8') == README.read_text(encoding='utf-8') + assert generated.read_bytes() == README.read_bytes() + assert b'\r' not in generated.read_bytes() + + +def test_readme_check_rejects_crlf_output(tmp_path: Path) -> None: + generated = tmp_path / 'README.generated.md' + subprocess.run( + [sys.executable, str(SCRIPT), '--output', str(generated)], + cwd=REPO_ROOT, + check=True, + ) + generated.write_bytes(generated.read_bytes().replace(b'\n', b'\r\n')) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + '--output', + str(generated), + '--check', + ], + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert 'out of sync' in result.stderr diff --git a/tests/meta/test_registry_integrity.py b/tests/meta/test_registry_integrity.py index a32b44c..ebe72e6 100644 --- a/tests/meta/test_registry_integrity.py +++ b/tests/meta/test_registry_integrity.py @@ -8,6 +8,18 @@ _ALLOWED_USAGE_ROLES = {"target", "support"} +_PRE_STAGE_2_SCALAR_REFS = ( + ar.DatasetRef("covalent_radius", "cordero2008"), + ar.DatasetRef("covalent_radius", "csd_legacy_cov"), + ar.DatasetRef("van_der_waals_radius", "bondi1964"), + ar.DatasetRef("van_der_waals_radius", "rowland_taylor1996"), + ar.DatasetRef("van_der_waals_radius", "alvarez2013"), + ar.DatasetRef("van_der_waals_radius", "chernyshov2020"), + ar.DatasetRef("van_der_waals_radius", "csd_legacy_vdw"), + ar.DatasetRef("atomic_radius", "rahm2016"), + ar.DatasetRef("xh_bond_length", "csd_legacy_xh_cno"), +) + def test_dataset_aliases_are_unique_within_each_quantity() -> None: for quantity in ar.list_quantities(): @@ -35,21 +47,32 @@ def test_every_built_in_dataset_loads_and_matches_coverage_metadata() -> None: assert info.references assert info.coverage is not None + if isinstance(dataset, ar.ElementScalarSet): + values_by_z = dataset.values_by_z + else: + assert isinstance(dataset, ar.ElementRadialSet) + values_by_z = dataset.profiles_by_z + max_z = ( info.coverage.z_max if info.coverage.z_max is not None - else len(dataset.values_by_z) - 1 + else len(values_by_z) - 1 ) covered_z = tuple( z - for z, value in enumerate(dataset.values_by_z) + for z, value in enumerate(values_by_z) if z > 0 and value is not None and z <= max_z ) covered_set = set(covered_z) missing_z = tuple(z for z in range(1, max_z + 1) if z not in covered_set) - has_placeholders = info.placeholder_value is not None and any( - value is not None and abs(value - info.placeholder_value) < 1e-12 - for value in dataset.values_by_z[1 : max_z + 1] + has_placeholders = ( + isinstance(dataset, ar.ElementScalarSet) + and info.placeholder_value is not None + and any( + value is not None + and abs(value - info.placeholder_value) < 1e-12 + for value in values_by_z[1 : max_z + 1] + ) ) coverage = asdict(info.coverage) @@ -63,6 +86,11 @@ def test_every_built_in_dataset_loads_and_matches_coverage_metadata() -> None: assert tuple(coverage["missing_z"]) == missing_z +def test_pre_stage_2_packaged_datasets_remain_scalar() -> None: + for ref in _PRE_STAGE_2_SCALAR_REFS: + assert isinstance(get_builtin_set(ref), ar.ElementScalarSet) + + def test_non_atomic_quantities_have_at_least_one_target_dataset() -> None: by_role: dict[str, list[str]] = defaultdict(list) for quantity in ar.list_quantities(): diff --git a/tests/meta/test_release_metadata.py b/tests/meta/test_release_metadata.py new file mode 100644 index 0000000..bb335f7 --- /dev/null +++ b/tests/meta/test_release_metadata.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path +import re + +from atomref import __version__ + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CITATION_METADATA = REPO_ROOT / "CITATION.cff" +CHANGELOG = REPO_ROOT / "CHANGELOG.md" + + +def _top_level_scalar(text: str, key: str) -> str: + """Return one simple top-level CFF scalar after schema validation.""" + + match = re.search( + rf"^{re.escape(key)}:\s*(?P.+)$", + text, + flags=re.MULTILINE, + ) + assert match is not None, f"missing top-level CFF field: {key}" + value = match.group("value").strip() + if value.startswith('"') and value.endswith('"'): + return value[1:-1] + return value + + +def _current_changelog_release() -> tuple[str, str]: + """Return the first version and date recorded in the changelog.""" + + text = CHANGELOG.read_text(encoding="utf-8") + match = re.search( + r"^## (?P\d+\.\d+\.\d+) - " + r"(?P\d{4}-\d{2}-\d{2})$", + text, + flags=re.MULTILINE, + ) + assert match is not None + return match.group("version"), match.group("date") + + +def test_citation_metadata_targets_the_versioned_software_repository() -> None: + text = CITATION_METADATA.read_text(encoding="utf-8") + changelog_version, changelog_date = _current_changelog_release() + + assert _top_level_scalar(text, "cff-version") == "1.2.0" + assert _top_level_scalar(text, "title") == "atomref" + assert _top_level_scalar(text, "type") == "software" + assert _top_level_scalar(text, "version") == __version__ == changelog_version + assert _top_level_scalar(text, "date-released") == changelog_date + assert _top_level_scalar(text, "repository-code") == ( + "https://github.com/DeloneCommons/atomref" + ) + assert _top_level_scalar(text, "license") == "LGPL-3.0-or-later" + + +def test_citation_abstract_describes_the_mixed_license_and_hash_locations() -> None: + text = CITATION_METADATA.read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "CC BY 4.0" in normalized + assert ( + "NOTICE.md for the exact licensing boundary, attribution, and source DOIs." + in normalized + ) + assert ( + "The exact source commit and SHA-256 hashes are recorded in the packaged " + "registry metadata." + in normalized + ) + assert re.search(r"^preferred-citation:", text, flags=re.MULTILINE) is None + + +def test_citation_cff_is_the_only_repository_deposit_metadata_file() -> None: + assert CITATION_METADATA.is_file() + assert not (REPO_ROOT / ".zenodo.json").exists() diff --git a/tests/meta/test_release_tools.py b/tests/meta/test_release_tools.py index 7cbff90..6f80dc4 100644 --- a/tests/meta/test_release_tools.py +++ b/tests/meta/test_release_tools.py @@ -1,11 +1,199 @@ from __future__ import annotations +import hashlib +import importlib.util +import io import subprocess import sys from pathlib import Path +import zipfile + +import pytest REPO_ROOT = Path(__file__).resolve().parents[2] +CHECK_DIST_PATH = REPO_ROOT / "tools" / "check_dist.py" +RELEASE_CHECK_PATH = REPO_ROOT / "tools" / "release_check.py" +SNAPSHOT_PATH = REPO_ROOT / "src" / "atomref" / "data" / ( + "proatomic_density_neutral.zip" +) +VALID_WHEEL_METADATA = b"""\ +Metadata-Version: 2.4 +Name: atomref +Version: 0.2.1 +Provides-Extra: all +Provides-Extra: dev +Provides-Extra: docs +Provides-Extra: notebooks +Provides-Extra: test +Requires-Dist: build>=1.2; extra == 'dev' +Requires-Dist: mkdocs-material>=9.5; extra == 'docs' +Requires-Dist: ipykernel>=6.29; extra == 'notebooks' +Requires-Dist: pytest>=7; extra == 'test' +Requires-Dist: build>=1.2; extra == 'all' +Requires-Dist: mkdocs-material>=9.5; extra == 'all' +Requires-Dist: ipykernel>=6.29; extra == 'all' +Requires-Dist: pytest>=7; extra == 'all' + +""" + +spec = importlib.util.spec_from_file_location("check_dist_tool", CHECK_DIST_PATH) +assert spec is not None and spec.loader is not None +check_dist = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = check_dist +spec.loader.exec_module(check_dist) + +release_spec = importlib.util.spec_from_file_location( + "release_check_tool", RELEASE_CHECK_PATH +) +assert release_spec is not None and release_spec.loader is not None +release_check = importlib.util.module_from_spec(release_spec) +sys.modules[release_spec.name] = release_check +release_spec.loader.exec_module(release_check) + + +def _snapshot_with_modified_csv(payload: bytes) -> bytes: + with zipfile.ZipFile(io.BytesIO(payload), mode="r") as archive: + csv_payload = bytearray(archive.read(check_dist.PROATOMIC_SNAPSHOT_MEMBER)) + csv_payload[len(csv_payload) // 2] ^= 1 + + output = io.BytesIO() + with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(check_dist.PROATOMIC_SNAPSHOT_MEMBER, csv_payload) + return output.getvalue() + + +def test_dist_check_accepts_pinned_proatomic_snapshot() -> None: + check_dist._assert_proatomic_snapshot( + SNAPSHOT_PATH.read_bytes(), + member=SNAPSHOT_PATH.name, + label="source tree", + ) + + +def test_dist_check_requires_release_tools_and_legacy_data() -> None: + assert "atomref/data/xh_bond_length.csv" in check_dist.REQUIRED_WHEEL_MEMBERS + assert "src/atomref/data/xh_bond_length.csv" in ( + check_dist.REQUIRED_SDIST_SUFFIXES + ) + assert ".flake8" in check_dist.REQUIRED_SDIST_SUFFIXES + assert "tools/check_registry.py" in check_dist.REQUIRED_SDIST_SUFFIXES + assert "tools/check_dist.py" in check_dist.REQUIRED_SDIST_SUFFIXES + assert "CITATION.cff" in check_dist.REQUIRED_SDIST_SUFFIXES + assert "docs/notebooks/05-proatomic-density-and-ias.ipynb" in ( + check_dist.REQUIRED_SDIST_SUFFIXES + ) + assert "tools/export_notebooks.py" not in check_dist.REQUIRED_SDIST_SUFFIXES + assert "dist-info/licenses/COPYING" in check_dist.REQUIRED_WHEEL_MEMBERS + + +def test_sdist_root_readme_cannot_be_satisfied_by_tools_readme() -> None: + with pytest.raises(check_dist.DistCheckError, match="root-level 'README.md'"): + check_dist._sdist_root_member( + {"atomref-9.9.9/tools/README.md"}, + "README.md", + label="test sdist", + ) + + +def test_sdist_layout_rejects_obsolete_notebook_paths() -> None: + members = { + "atomref-0.2.1/docs/notebooks/01-quickstart.ipynb", + "atomref-0.2.1/notebooks/01-quickstart.ipynb", + } + + with pytest.raises(check_dist.DistCheckError, match="exactly one source"): + check_dist._assert_sdist_layout(members, label="test sdist") + + +def test_sdist_layout_rejects_generated_notebook_markdown() -> None: + members = { + f"atomref-0.2.1/{name}" for name in check_dist.EXPECTED_SDIST_NOTEBOOKS + } + members.add("atomref-0.2.1/docs/notebooks/01-quickstart.md") + + with pytest.raises(check_dist.DistCheckError, match="obsolete members"): + check_dist._assert_sdist_layout(members, label="test sdist") + + +def test_wheel_metadata_accepts_empty_runtime_and_complete_all_extra() -> None: + check_dist._assert_wheel_metadata( + VALID_WHEEL_METADATA, + member="atomref.dist-info/METADATA", + label="test wheel", + ) + + +def test_wheel_metadata_rejects_incomplete_all_extra() -> None: + payload = VALID_WHEEL_METADATA.replace( + b"Requires-Dist: pytest>=7; extra == 'all'\n", + b"", + ) + + with pytest.raises(check_dist.DistCheckError, match="all extra must equal"): + check_dist._assert_wheel_metadata( + payload, + member="atomref.dist-info/METADATA", + label="test wheel", + ) + + +def test_dist_check_accepts_conventional_regular_file_modes() -> None: + check_dist._assert_regular_file_modes( + [("atomref/module.py", 0o100644), ("atomref/data/table.csv", 0o644)], + label="test artifact", + ) + + +def test_dist_check_rejects_executable_payload_file_modes() -> None: + with pytest.raises(check_dist.DistCheckError, match="module.py=0755"): + check_dist._assert_regular_file_modes( + [("atomref/module.py", 0o100755)], + label="test artifact", + ) + + +def test_wheel_metadata_rejects_runtime_dependencies() -> None: + payload = ( + VALID_WHEEL_METADATA.rstrip() + + b"\nRequires-Dist: numpy>=2\n\n" + ) + + with pytest.raises(check_dist.DistCheckError, match="runtime requirements"): + check_dist._assert_wheel_metadata( + payload, + member="atomref.dist-info/METADATA", + label="test wheel", + ) + + +def test_dist_check_rejects_changed_snapshot_archive() -> None: + payload = SNAPSHOT_PATH.read_bytes() + b"altered" + + with pytest.raises(check_dist.DistCheckError, match="snapshot SHA-256"): + check_dist._assert_proatomic_snapshot( + payload, + member=SNAPSHOT_PATH.name, + label="test wheel", + ) + + +def test_dist_check_independently_rejects_changed_inner_csv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = _snapshot_with_modified_csv(SNAPSHOT_PATH.read_bytes()) + monkeypatch.setattr( + check_dist, + "EXPECTED_PROATOMIC_SNAPSHOT_SHA256", + hashlib.sha256(payload).hexdigest(), + ) + + with pytest.raises(check_dist.DistCheckError, match="inner CSV SHA-256"): + check_dist._assert_proatomic_snapshot( + payload, + member=SNAPSHOT_PATH.name, + label="test sdist", + ) # Keeping this as a subprocess test ensures the helper stays importable and @@ -20,3 +208,128 @@ def test_release_check_help() -> None: text=True, ) assert "release-preparation checks" in result.stdout + assert "--skip-install-checks" in result.stdout + + +def test_release_docs_build_suppresses_material_banner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def record(*args: str, **kwargs: object) -> None: + calls.append((args, kwargs)) + + monkeypatch.setattr(release_check, "_run", record) + release_check._build_docs() + + assert calls == [ + ( + ("mkdocs", "build", "--strict"), + {"extra_env": {"NO_MKDOCS_2_WARNING": "true"}}, + ) + ] + + +def test_release_command_merges_extra_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def record(args: tuple[str, ...], **kwargs: object) -> None: + captured["args"] = args + captured.update(kwargs) + + monkeypatch.setenv("ATOMREF_PARENT_ENV", "preserved") + monkeypatch.setattr(release_check.subprocess, "run", record) + release_check._run( + "mkdocs", + "build", + extra_env={"NO_MKDOCS_2_WARNING": "true"}, + timeout=123, + ) + + environment = captured["env"] + assert isinstance(environment, dict) + assert environment["ATOMREF_PARENT_ENV"] == "preserved" + assert environment["NO_MKDOCS_2_WARNING"] == "true" + assert captured["timeout"] == 123 + + +def test_release_notebook_check_has_outer_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def record(*args: str, **kwargs: object) -> None: + calls.append((args, kwargs)) + + monkeypatch.setattr(release_check, "_run", record) + + release_check._check_notebooks() + + assert calls == [ + ( + (sys.executable, "tools/check_notebooks.py"), + {"timeout": release_check.NOTEBOOK_CHECK_TIMEOUT_SECONDS}, + ) + ] + + +def test_release_type_check_uses_running_python( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def record(*args: str, **kwargs: object) -> None: + calls.append((args, kwargs)) + + monkeypatch.setattr(release_check, "_run", record) + + release_check._check_types() + + assert calls == [ + ((sys.executable, "-m", "mypy", "src/atomref"), {}), + ] + + +def test_release_source_archive_uses_explicit_safe_filter( + tmp_path: Path, +) -> None: + calls: list[tuple[Path, dict[str, str]]] = [] + + class RecordingArchive: + def extractall(self, path: Path, **kwargs: str) -> None: + calls.append((path, kwargs)) + + archive = RecordingArchive() + release_check._extract_source_archive(archive, tmp_path) + + expected_kwargs = ( + {"filter": "data"} + if hasattr(release_check.tarfile, "data_filter") + else {} + ) + assert calls == [(tmp_path, expected_kwargs)] + + +@pytest.mark.parametrize( + "workflow", + [REPO_ROOT / ".github/workflows/ci.yml", REPO_ROOT / ".github/workflows/docs.yml"], +) +def test_docs_workflows_suppress_material_banner(workflow: Path) -> None: + text = workflow.read_text(encoding="utf-8") + + assert 'NO_MKDOCS_2_WARNING: "true"' in text + + +@pytest.mark.parametrize( + "workflow", + [REPO_ROOT / ".github/workflows/ci.yml", REPO_ROOT / ".github/workflows/docs.yml"], +) +def test_workflows_use_current_node_action_generations(workflow: Path) -> None: + text = workflow.read_text(encoding="utf-8") + + assert "actions/checkout@v7" in text + assert "actions/setup-python@v6" in text + assert "actions/checkout@v4" not in text + assert "actions/setup-python@v5" not in text diff --git a/tests/meta/test_text_generation_tools.py b/tests/meta/test_text_generation_tools.py deleted file mode 100644 index b6203a7..0000000 --- a/tests/meta/test_text_generation_tools.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -import sys - - -REPO_ROOT = Path(__file__).resolve().parents[2] -MODULE_PATH = REPO_ROOT / "tools" / "export_notebooks.py" - -spec = importlib.util.spec_from_file_location("export_notebooks_tool", MODULE_PATH) -assert spec is not None and spec.loader is not None -export_notebooks = importlib.util.module_from_spec(spec) -sys.modules[spec.name] = export_notebooks -spec.loader.exec_module(export_notebooks) - - -def test_export_notebooks_check_ignores_crlf(tmp_path: Path) -> None: - """Notebook export checks should ignore Windows vs Unix newline differences.""" - - output_dir = tmp_path / "docs" - output_dir.mkdir() - - for notebook_name, output_name in export_notebooks.NOTEBOOK_OUTPUTS.items(): - rendered = export_notebooks._export_markdown( - export_notebooks.NOTEBOOKS / notebook_name - ) - (output_dir / output_name).write_text( - rendered.replace("\n", "\r\n"), - encoding="utf-8", - newline="", - ) - - assert export_notebooks.export_notebooks(output_dir, check=True) == 0 diff --git a/tests/proatoms/test_dataset.py b/tests/proatoms/test_dataset.py new file mode 100644 index 0000000..0ecb65c --- /dev/null +++ b/tests/proatoms/test_dataset.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import csv +import hashlib +from importlib import resources +import io +import math +import zipfile + +import atomref as ar + + +_DATASET_ID = "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2" +_ARCHIVE_NAME = "proatomic_density_neutral.zip" +_MEMBER_NAME = "proatomic_density_neutral.csv" +_EXPECTED_HEADER = ("r_bohr", *(f"z{z:03d}" for z in range(1, 104))) +_EXPECTED_ROWS = 1127 +_MONOTONIC_REL_TOL = 1.0e-12 +_EXPECTED_ARCHIVE_SHA256 = ( + "1ec0318c8bc8f6e71eb3125cf1d4387e4593d7bee8ff5ee5270fbcc32c70ec6b" +) +_EXPECTED_CSV_SHA256 = ( + "8478da862233c8874e36d65bb5eb762cdb9cbcb0e0278733c0f425ae00c2dcfe" +) + + +def _snapshot_csv_bytes() -> tuple[bytes, zipfile.ZipInfo, bytes]: + archive_bytes = ( + resources.files("atomref.data").joinpath(_ARCHIVE_NAME).read_bytes() + ) + with zipfile.ZipFile(io.BytesIO(archive_bytes), mode="r") as archive: + assert archive.comment == b"" + members = archive.infolist() + assert len(members) == 1 + member = members[0] + csv_bytes = archive.read(member) + return archive_bytes, member, csv_bytes + + +def test_neutral_snapshot_matches_pinned_scientific_fingerprints() -> None: + archive_bytes, _, csv_bytes = _snapshot_csv_bytes() + + assert hashlib.sha256(archive_bytes).hexdigest() == _EXPECTED_ARCHIVE_SHA256 + assert hashlib.sha256(csv_bytes).hexdigest() == _EXPECTED_CSV_SHA256 + + +def test_neutral_snapshot_has_exact_zip_and_csv_contract() -> None: + _, member, csv_bytes = _snapshot_csv_bytes() + + assert member.filename == _MEMBER_NAME + assert not member.is_dir() + assert member.date_time == (1980, 1, 1, 0, 0, 0) + assert member.compress_type == zipfile.ZIP_DEFLATED + assert member.create_system == 3 + assert member.external_attr == 0o100644 << 16 + assert member.internal_attr == 0 + assert member.extra == b"" + assert member.comment == b"" + assert member.flag_bits & 0x1 == 0 + + assert csv_bytes.endswith(b"\n") + assert b"\r" not in csv_bytes + rows = list(csv.reader(io.StringIO(csv_bytes.decode("utf-8"), newline=""))) + assert tuple(rows[0]) == _EXPECTED_HEADER + assert len(rows) - 1 == _EXPECTED_ROWS + assert all(len(row) == len(_EXPECTED_HEADER) for row in rows[1:]) + + +def test_neutral_snapshot_values_satisfy_the_retained_domain_contract() -> None: + _, _, csv_bytes = _snapshot_csv_bytes() + rows = list(csv.reader(io.StringIO(csv_bytes.decode("utf-8"), newline=""))) + data_rows = rows[1:] + + radii = tuple(float(row[0]) for row in data_rows) + assert len(radii) == _EXPECTED_ROWS + assert all(math.isfinite(radius) and radius > 0.0 for radius in radii) + assert all(right > left for left, right in zip(radii, radii[1:])) + assert radii[-2] == 19.865456344881434 + assert radii[-2] < 20.0 < radii[-1] + assert radii[-1] == 20.1644204667093 + + for column_index in range(1, 104): + profile = tuple(float(row[column_index]) for row in data_rows) + assert all(math.isfinite(value) and value > 0.0 for value in profile) + assert all( + current <= previous + or math.isclose( + current, + previous, + rel_tol=_MONOTONIC_REL_TOL, + abs_tol=0.0, + ) + for previous, current in zip(profile, profile[1:]) + ) + + +def test_neutral_snapshot_uses_generic_registry_discovery_and_caching() -> None: + ref = ar.DatasetRef("proatomic_density", _DATASET_ID) + + assert "proatomic_density" in ar.list_quantities() + assert ar.list_dataset_ids("proatomic_density") == (_DATASET_ID,) + assert tuple( + info.ref.set_id for info in ar.list_dataset_infos("proatomic_density") + ) == (_DATASET_ID,) + + info = ar.get_dataset_info(ref) + alias_info = ar.get_dataset_info( + ar.DatasetRef("proatomic_density", "atomref-proatoms neutral v2") + ) + assert alias_info.ref == ref + assert info.coverage is not None + assert info.coverage.n_values == 103 + assert info.coverage.z_min == 1 + assert info.coverage.z_max == 103 + assert info.coverage.has_placeholders is False + + dataset = ar.get_builtin_set(ref) + via_alias = ar.get_builtin_set( + ar.DatasetRef("proatomic_density", "atomref-proatoms neutral v2") + ) + assert isinstance(dataset, ar.ElementRadialSet) + assert via_alias is dataset + assert dataset.info is info or dataset.info == info + assert len(dataset.radii) == _EXPECTED_ROWS + assert all(dataset.profiles_by_z[z] is not None for z in range(1, 104)) + assert all(dataset.profiles_by_z[z] is None for z in range(104, 119)) + assert all( + len(dataset.profiles_by_z[z] or ()) == _EXPECTED_ROWS + for z in range(1, 104) + ) + + +def test_neutral_snapshot_registry_records_pinned_source_and_license() -> None: + info = ar.get_dataset_info( + ar.DatasetRef("proatomic_density", _DATASET_ID) + ) + assert info.storage is not None + storage = info.storage + + expected = { + "kind": "element_radial_csv_zip", + "filename": _ARCHIVE_NAME, + "member": _MEMBER_NAME, + "radius_column": "r_bohr", + "density_column_pattern": "z{z:03d}", + "native_coordinate_unit": "bohr", + "native_density_unit": "electron/bohr^3", + "public_max_radius_bohr": 20.0, + "retained_bracketing_radius_bohr": 20.1644204667093, + "retained_rows": _EXPECTED_ROWS, + "interpolation_contract": "loglog_positive_bracketed_v1", + "monotonicity_relative_tolerance": _MONOTONIC_REL_TOL, + "charge_scope": "neutral atoms only", + "source_project": "atomref-proatoms", + "source_release": "2.0.0", + "source_dataset_id": "pbe0_sfx2c_dyallv4z_h-lr_spherical_v2", + "source_profiles_sha256": ( + "b5520ab009542d52098dd6dbb920966d8d13377a4a5004f584a7bd15cd41c299" + ), + "source_metadata_sha256": ( + "32c833ca69fa0f7eb9ed32841aafc638123ff872861e636156610e417fc4c514" + ), + "basis_id": "dyall-v4z", + "basis_sha256": ( + "0ee543855f8b1e7fbe9868d4abb844d8e8cc8b8c2694067b2b40de014bb4be94" + ), + "profile_data_version": "2.0.0", + "electronic_method": "PBE0", + "scf_model": "self-consistent spherical fractional-occupation UKS", + "relativity": "spin-free one-electron X2C", + "data_license": "CC BY 4.0", + "data_license_url": "https://creativecommons.org/licenses/by/4.0/", + "concept_doi": "10.5281/zenodo.21291021", + "version_doi": "10.5281/zenodo.21291022", + } + assert {key: storage[key] for key in expected} == expected + assert {reference.doi for reference in info.references} == { + "10.5281/zenodo.21291021", + "10.5281/zenodo.21291022", + } + assert "CC BY 4.0" in " ".join(info.notes) diff --git a/tests/proatoms/test_density.py b/tests/proatoms/test_density.py new file mode 100644 index 0000000..3984b20 --- /dev/null +++ b/tests/proatoms/test_density.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +import math +from types import MappingProxyType + +import pytest + +import atomref as ar +from atomref.errors import DatasetError +import atomref.proatoms as proatoms +import atomref.registry as registry + + +_DATASET_ID = "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2" + + +def _synthetic_power_law_profile() -> ar.ProatomicDensityProfile: + ref = ar.DatasetRef("proatomic_density", "synthetic_power_law") + info = ar.DatasetInfo( + ref=ref, + domain="element", + units="electron/bohr^3", + name="Synthetic power law", + storage=MappingProxyType( + { + "native_coordinate_unit": "bohr", + "native_density_unit": "electron/bohr^3", + "public_max_radius_bohr": 8.0, + "interpolation_contract": "loglog_positive_bracketed_v1", + } + ), + ) + radii = (0.25, 1.0, 4.0, 8.0) + coefficient = 7.25 + exponent = -2.75 + densities = tuple(coefficient * radius**exponent for radius in radii) + dataset = ar.ElementRadialSet( + ref=ref, + info=info, + radii=radii, + profiles_by_z=(None, densities), + ) + return ar.ProatomicDensityProfile( + dataset=dataset, + atomic_number=1, + ) + + +def test_density_set_discovery_aliases_and_generic_loader_identity() -> None: + assert ar.list_proatomic_density_sets() == (_DATASET_ID,) + assert tuple( + info.ref.set_id for info in ar.list_proatomic_density_set_infos() + ) == (_DATASET_ID,) + + info = ar.get_proatomic_density_set_info("atomref-proatoms neutral v2") + dataset = ar.get_proatomic_density_set("atomref-proatoms neutral v2") + generic = ar.get_builtin_set(ar.DatasetRef("proatomic_density", _DATASET_ID)) + assert info.ref.set_id == _DATASET_ID + assert dataset is generic + assert isinstance(dataset, ar.ProatomicDensitySet) + + +@pytest.mark.parametrize("symbol", ["H", "C", "O", "Fe", "La", "U", "Lr"]) +def test_representative_h_through_lr_profiles_load(symbol: str) -> None: + profile = ar.get_proatomic_density_profile(symbol) + assert profile is not None + assert profile.symbol == symbol + assert profile.atomic_number == ar.get_element(symbol).z + assert profile.dataset is ar.get_proatomic_density_set() + assert profile.ref == profile.info.ref + + +def test_all_h_through_lr_profiles_are_available() -> None: + for element in ar.iter_elements(): + if element.z > 103: + break + profile = ar.get_proatomic_density_profile(element.symbol) + assert profile is not None, element.symbol + assert profile.atomic_number == element.z + + +@pytest.mark.parametrize( + ("atomic_number", "symbol"), + [(1, "H"), (8, "O"), (103, "Lr")], +) +def test_integer_atomic_numbers_use_canonical_cached_profiles( + atomic_number: int, + symbol: str, +) -> None: + profile = ar.get_proatomic_density_profile(atomic_number) + assert profile is ar.get_proatomic_density_profile(symbol) + assert profile is not None + assert profile.atomic_number == atomic_number + assert profile.symbol == symbol + assert ar.get_proatomic_density( + atomic_number, + 1.0, + radius_unit="bohr", + ) == ar.get_proatomic_density(symbol, 1.0, radius_unit="bohr") + + +@pytest.mark.parametrize("atomic_number", [104, 0, -1, True, False]) +def test_invalid_or_unsupported_atomic_numbers_return_none( + atomic_number: int, +) -> None: + assert ar.get_proatomic_density_profile(atomic_number) is None + assert ar.get_proatomic_density(atomic_number, 1.0) is None + + +def test_bool_does_not_poison_integer_atomic_number_resolution_cache() -> None: + proatoms._get_element_by_atomic_number_cached.cache_clear() + assert ar.get_proatomic_density_profile(True) is None + assert ar.get_proatomic_density_profile(1) is ar.get_proatomic_density_profile( + "H" + ) + + +def test_isotopes_invalid_symbols_and_unsupported_elements() -> None: + hydrogen = ar.get_proatomic_density_profile("H") + assert ar.get_proatomic_density_profile("D") is hydrogen + assert ar.get_proatomic_density_profile("T") is hydrogen + assert ar.get_proatomic_density("D", 0.5) == ar.get_proatomic_density("H", 0.5) + assert ar.get_proatomic_density("T", 0.5) == ar.get_proatomic_density("H", 0.5) + for symbol in ("Rf", "Og", "not-an-element", "", None): + assert ar.get_proatomic_density_profile(symbol) is None + assert ar.get_proatomic_density(symbol, 0.5) is None + + +def test_set_and_profile_cache_reuse_and_public_immutability() -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + assert ar.get_proatomic_density_profile("o") is profile + assert ar.get_proatomic_density_profile("O") is profile + assert isinstance(profile.radii, tuple) + assert isinstance(profile.densities, tuple) + with pytest.raises(FrozenInstanceError): + profile.symbol = "N" + with pytest.raises(TypeError): + profile.densities[0] = 0.0 + with pytest.raises(FrozenInstanceError): + profile.dataset.radii = () + + +def test_profile_identity_is_derived_and_repr_is_concise() -> None: + dataset = ar.get_proatomic_density_set() + profile = ar.ProatomicDensityProfile(dataset=dataset, atomic_number=1) + assert profile.symbol == "H" + assert profile.atomic_number == 1 + assert repr(profile) == ( + "ProatomicDensityProfile(atomic_number=1, symbol='H')" + ) + assert len(repr(profile)) < 100 + with pytest.raises(TypeError, match="unexpected keyword argument 'symbol'"): + ar.ProatomicDensityProfile( + dataset=dataset, + atomic_number=1, + symbol="O", + ) + + +def test_profile_rejects_dataset_metadata_reference_mismatch() -> None: + valid = _synthetic_power_law_profile() + mismatched_info = ar.DatasetInfo( + ref=ar.DatasetRef("proatomic_density", "different"), + domain=valid.info.domain, + units=valid.info.units, + name=valid.info.name, + storage=valid.info.storage, + ) + mismatched = ar.ElementRadialSet( + ref=valid.ref, + info=mismatched_info, + radii=valid.radii, + profiles_by_z=valid.dataset.profiles_by_z, + ) + with pytest.raises(DatasetError, match="reference does not match"): + ar.ProatomicDensityProfile(dataset=mismatched, atomic_number=1) + + +def test_packaged_density_loading_is_lazy_and_shared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_reader = registry._read_package_data_bytes + calls = 0 + + def counting_reader(filename: str) -> bytes: + nonlocal calls + calls += 1 + return real_reader(filename) + + registry._load_builtin_set.cache_clear() + registry._load_radial_csv_zip.cache_clear() + proatoms._get_profile_cached.cache_clear() + monkeypatch.setattr(registry, "_read_package_data_bytes", counting_reader) + try: + ar.get_proatomic_density_set_info() + assert calls == 0 + first = ar.get_proatomic_density_profile("O") + assert first is not None + assert calls == 1 + assert ar.get_proatomic_density_profile("O") is first + assert ar.get_proatomic_density("O", 0.75) is not None + assert calls == 1 + finally: + registry._load_builtin_set.cache_clear() + registry._load_radial_csv_zip.cache_clear() + proatoms._get_profile_cached.cache_clear() + + +def test_origin_first_grid_point_and_exact_stored_knots() -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + assert profile(0.0, radius_unit="bohr") == 358.401594629436 + assert profile(profile.radii[0] / 2.0, radius_unit="bohr") == ( + 358.401594629436 + ) + assert profile(profile.radii[0], radius_unit="bohr") == 358.401594629436 + expected_knots = { + 100: (4.453688832606716e-06, 358.4005490198518), + 500: (0.0017522632545894682, 326.9578641098411), + 1000: (3.0704265133844744, 0.0015857375047883448), + 1125: (19.865456344881434, 1.1086022497194577e-36), + } + for index, (radius, density) in expected_knots.items(): + assert profile.radii[index] == radius + assert profile.densities[index] == density + assert profile(radius, radius_unit="bohr") == density + + +def test_exact_public_endpoint_and_out_of_range_radii() -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + endpoint = profile(20.0, radius_unit="bohr") + endpoint_angstrom = profile(20.0 * ar.BOHR_TO_ANGSTROM) + assert math.isfinite(endpoint) and endpoint > 0.0 + assert endpoint_angstrom == pytest.approx(endpoint, rel=2.0e-15) + with pytest.raises(ValueError, match="exceeds the public limit"): + profile(math.nextafter(20.0, math.inf), radius_unit="bohr") + with pytest.raises(ValueError, match="exceeds the public limit"): + profile(20.01, radius_unit="bohr") + with pytest.raises(ValueError, match="exceeds the public limit"): + profile(math.nextafter(20.0 * ar.BOHR_TO_ANGSTROM, math.inf)) + + +@pytest.mark.parametrize("radius", [-1.0, math.nan, math.inf, -math.inf]) +def test_negative_and_non_finite_radii_raise(radius: float) -> None: + with pytest.raises(ValueError, match="radius"): + ar.get_proatomic_density("O", radius, radius_unit="bohr") + + +@pytest.mark.parametrize( + "radius", + [ + -1.0, + math.nan, + math.inf, + -math.inf, + math.nextafter(20.0, math.inf), + [1.0], + object(), + True, + False, + ], + ids=[ + "negative", + "nan", + "positive-infinity", + "negative-infinity", + "above-public-limit", + "nonscalar", + "nonconvertible", + "true", + "false", + ], +) +def test_missing_profile_does_not_hide_invalid_radius(radius: object) -> None: + with pytest.raises(ValueError, match="radius"): + ar.get_proatomic_density("Og", radius, radius_unit="bohr") + + +@pytest.mark.parametrize("radius", [True, False]) +@pytest.mark.parametrize("api", ["evaluate", "call", "accessor"]) +def test_boolean_radii_are_rejected_by_every_density_api( + radius: bool, + api: str, +) -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + + with pytest.raises(ValueError, match="radius"): + if api == "evaluate": + profile.evaluate(radius, radius_unit="bohr") + elif api == "call": + profile(radius, radius_unit="bohr") + else: + ar.get_proatomic_density("O", radius, radius_unit="bohr") + + +def test_unknown_units_raise() -> None: + with pytest.raises(ValueError, match="unknown radius unit"): + ar.get_proatomic_density("O", 1.0, radius_unit="meter") + with pytest.raises(ValueError, match="unknown density unit"): + ar.get_proatomic_density("O", 1.0, density_unit="kg/m^3") + + +def test_synthetic_power_law_is_exact_under_loglog_interpolation() -> None: + profile = _synthetic_power_law_profile() + coefficient = 7.25 + exponent = -2.75 + for radius in (0.5, 2.0, 6.0): + expected = coefficient * radius**exponent + assert profile(radius, radius_unit="bohr") == pytest.approx( + expected, + rel=2.0e-15, + ) + + +def test_interpolation_is_loglog_positive_and_continuous_at_knots() -> None: + profile = _synthetic_power_law_profile() + left_density = profile.densities[1] + right_density = profile.densities[2] + midpoint = math.sqrt(profile.radii[1] * profile.radii[2]) + expected_loglog = math.sqrt(left_density * right_density) + linear_midpoint = (left_density + right_density) / 2.0 + interpolated = profile(midpoint, radius_unit="bohr") + assert interpolated == pytest.approx(expected_loglog, rel=2.0e-15) + assert not math.isclose(interpolated, linear_midpoint, rel_tol=1.0e-3) + assert interpolated > 0.0 + + for knot in profile.radii[1:-1]: + exact = profile(knot, radius_unit="bohr") + left = profile(math.nextafter(knot, 0.0), radius_unit="bohr") + right = profile(math.nextafter(knot, math.inf), radius_unit="bohr") + assert left == pytest.approx(exact, rel=2.0e-14) + assert right == pytest.approx(exact, rel=2.0e-14) + + +def test_nonpositive_density_data_are_rejected_without_zero_fill() -> None: + valid = _synthetic_power_law_profile() + invalid_dataset = ar.ElementRadialSet( + ref=valid.ref, + info=valid.info, + radii=valid.radii, + profiles_by_z=(None, (valid.densities[0], 0.0, *valid.densities[2:])), + ) + with pytest.raises(DatasetError, match="finite and positive"): + ar.ProatomicDensityProfile( + dataset=invalid_dataset, + atomic_number=1, + ) + + +@pytest.mark.parametrize("symbol", ["H", "O", "Fe", "U", "Lr"]) +def test_packaged_profiles_are_positive_and_non_increasing(symbol: str) -> None: + profile = ar.get_proatomic_density_profile(symbol) + assert profile is not None + assert all(value > 0.0 for value in profile.densities) + assert all( + current <= previous + or math.isclose(current, previous, rel_tol=1.0e-12, abs_tol=0.0) + for previous, current in zip(profile.densities, profile.densities[1:]) + ) + + +def test_radius_and_density_units_are_independent() -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + radius_bohr = 1.75 + assert ar.BOHR_TO_ANGSTROM == 0.529177210903 + radius_angstrom = radius_bohr * 0.529177210903 + native = profile(radius_bohr, radius_unit="bohr") + equivalent = profile(radius_angstrom, radius_unit="angstrom") + converted = profile( + radius_bohr, + radius_unit="bohr", + density_unit="electron/angstrom^3", + ) + assert equivalent == pytest.approx(native, rel=2.0e-15) + assert converted == pytest.approx( + native / 0.529177210903**3, + rel=2.0e-15, + ) + assert profile.ref == ar.get_proatomic_density_profile("O").ref + assert profile.interpolation_contract == "loglog_positive_bracketed_v1" + + +def test_documented_units_are_function_defaults() -> None: + profile = ar.get_proatomic_density_profile("O") + assert profile is not None + radius_angstrom = 0.75 + assert profile(radius_angstrom) == profile( + radius_angstrom, + radius_unit="angstrom", + density_unit="electron/bohr^3", + ) + assert ar.get_proatomic_density("O", radius_angstrom) == profile(radius_angstrom) + + +def test_missing_density_dataset_raises_dataset_error() -> None: + with pytest.raises(DatasetError, match="unknown dataset id"): + ar.get_proatomic_density_profile("O", set_id="missing") + + +def test_profile_validates_dataset_before_missing_element() -> None: + with pytest.raises(DatasetError, match="unknown dataset id"): + ar.get_proatomic_density_profile("Xx", set_id="missing") + + +def test_density_validates_dataset_before_missing_element() -> None: + with pytest.raises(DatasetError, match="unknown dataset id"): + ar.get_proatomic_density("Xx", 1.0, set_id="missing") + + +def test_missing_profile_does_not_hide_invalid_radius_unit() -> None: + with pytest.raises(ValueError, match="unknown radius unit"): + ar.get_proatomic_density("Og", 1.0, radius_unit="meter") + + +def test_missing_profile_does_not_hide_invalid_density_unit() -> None: + with pytest.raises(ValueError, match="unknown density unit"): + ar.get_proatomic_density( + "Og", + 1.0, + density_unit="electron/meter^3", + ) + + +def test_scalar_accessors_remain_narrow_after_density_use() -> None: + assert isinstance( + ar.get_radii_set("covalent", "cordero2008"), + ar.ElementScalarSet, + ) + assert isinstance(ar.get_xh_set("csd_legacy_xh_cno"), ar.ElementScalarSet) + with pytest.raises(DatasetError, match="radial payload; scalar dataset required"): + ar.ValuePolicy(base=ar.get_proatomic_density_set()) diff --git a/tests/proatoms/test_ias.py b/tests/proatoms/test_ias.py new file mode 100644 index 0000000..d59f4d3 --- /dev/null +++ b/tests/proatoms/test_ias.py @@ -0,0 +1,1449 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +import inspect +import math +from types import MappingProxyType + +import pytest + +import atomref as ar +from atomref.errors import DatasetError +import atomref.proatoms as proatoms + + +_DATASET_ID = "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2" +_INTERPOLATION_CONTRACT = "loglog_positive_bracketed_v1" +_PAIRWISE_CONTRACT = "neutral_proatom_pairwise_cutoff_1e-4_resolution_0.01_v1" + +_AMBIGUOUS_CASE = ("Mn", "Gd", 9.699418444855935) +_BOUNDARY_DOMINATED_CASE = ("Zn", "He", 0.742706534980151) +_NO_RESOLVED_CASE = ("C", "Lu", 0.2217301367970158) +_UNSTABLE_CASE = ("Be", "Cm", 5.209173731112204) + + +def _result(value: ar.IASPositionResult | None) -> ar.IASPositionResult: + assert value is not None + return value + + +def _profile(symbol: str) -> ar.ProatomicDensityProfile: + profile = ar.get_proatomic_density_profile(symbol) + assert profile is not None + return profile + + +def _independent_cutoff_radius(profile: ar.ProatomicDensityProfile) -> float: + cutoff = ar.PROATOMIC_TAIL_CUTOFF + right = next( + index + for index, density in enumerate(profile.densities) + if density <= cutoff + ) + if profile.densities[right] == cutoff: + return profile.radii[right] + left = right - 1 + log_radius_left = math.log(profile.radii[left]) + log_radius_right = math.log(profile.radii[right]) + log_density_left = math.log(profile.densities[left]) + log_density_right = math.log(profile.densities[right]) + fraction = (math.log(cutoff) - log_density_left) / ( + log_density_right - log_density_left + ) + return math.exp( + log_radius_left + fraction * (log_radius_right - log_radius_left) + ) + + +def _pair_density_sum( + profile_a: ar.ProatomicDensityProfile, + profile_b: ar.ProatomicDensityProfile, + distance_bohr: float, + position_bohr: float, +) -> float: + return profile_a(position_bohr, radius_unit="bohr") + profile_b( + distance_bohr - position_bohr, + radius_unit="bohr", + ) + + +def _synthetic_profile( + densities: tuple[float, ...], + *, + radii: tuple[float, ...] = (0.1, 1.0, 4.0, 10.0, 20.0, 21.0), + set_id: str, +) -> ar.ProatomicDensityProfile: + ref = ar.DatasetRef("proatomic_density", set_id) + info = ar.DatasetInfo( + ref=ref, + domain="element", + units="electron/bohr^3", + name="Synthetic pairwise profile", + storage=MappingProxyType( + { + "native_coordinate_unit": "bohr", + "native_density_unit": "electron/bohr^3", + "public_max_radius_bohr": 20.0, + "interpolation_contract": _INTERPOLATION_CONTRACT, + } + ), + ) + dataset = ar.ElementRadialSet( + ref=ref, + info=info, + radii=radii, + profiles_by_z=(None, densities), + ) + return ar.ProatomicDensityProfile(dataset=dataset, atomic_number=1) + + +def _assert_reversal( + forward: ar.IASPositionResult, + reverse: ar.IASPositionResult, + distance_bohr: float, +) -> None: + assert reverse.method == forward.method + assert reverse.status == forward.status + assert reverse.cutoff_regime == forward.cutoff_regime + assert reverse.search_converged == forward.search_converged + assert reverse.search_passes == forward.search_passes + assert reverse.search_resolution == forward.search_resolution + assert reverse.relative_depth_gap == forward.relative_depth_gap + assert reverse.ambiguous == forward.ambiguous + assert reverse.cutoff_radius_a == forward.cutoff_radius_b + assert reverse.cutoff_radius_b == forward.cutoff_radius_a + assert reverse.contour_separation == forward.contour_separation + + if forward.position_from_a is None: + assert reverse.position_from_a is None + assert reverse.position_from_b is None + assert reverse.fraction_from_a is None + else: + assert reverse.position_from_a == pytest.approx( + distance_bohr - forward.position_from_a, + abs=2.0e-12, + ) + assert reverse.position_from_b == pytest.approx( + forward.position_from_a, + abs=2.0e-12, + ) + assert reverse.fraction_from_a == pytest.approx( + 1.0 - forward.fraction_from_a, + abs=2.0e-15, + ) + assert reverse.rho_a == pytest.approx(forward.rho_b, rel=2.0e-14) + assert reverse.rho_b == pytest.approx(forward.rho_a, rel=2.0e-14) + assert reverse.rho_sum == pytest.approx(forward.rho_sum, rel=2.0e-14) + + if forward.alternative_position_from_a is None: + assert reverse.alternative_position_from_a is None + assert reverse.alternative_position_from_b is None + assert reverse.alternative_rho_sum is None + else: + assert reverse.alternative_position_from_a == pytest.approx( + distance_bohr - forward.alternative_position_from_a, + abs=2.0e-12, + ) + assert reverse.alternative_position_from_b == pytest.approx( + forward.alternative_position_from_a, + abs=2.0e-12, + ) + assert reverse.alternative_rho_sum == pytest.approx( + forward.alternative_rho_sum, + rel=2.0e-14, + ) + + +def _assert_minimum_coordinates_strictly_inside_overlap( + result: ar.IASPositionResult, +) -> None: + """Check that exposed unlike-atom minima are not overlap endpoints.""" + + if result.requested_mode != "minimum" or result.atom_a == result.atom_b: + return + overlap_left = max(0.0, result.distance - result.cutoff_radius_b) + overlap_right = min(result.distance, result.cutoff_radius_a) + for position in ( + result.position_from_a, + result.alternative_position_from_a, + ): + if position is not None: + assert overlap_left < position < overlap_right + assert 0.0 < position < result.distance + + +def test_public_functions_and_dispatcher_default_are_consistent() -> None: + signature = inspect.signature(ar.estimate_ias_position) + assert signature.parameters["mode"].default == "boundary" + + direct_boundary = ar.estimate_proatomic_boundary( + "C", "O", 3.0, distance_unit="bohr" + ) + direct_minimum = ar.estimate_promolecular_density_minimum( + "C", "O", 3.0, distance_unit="bohr" + ) + assert ar.estimate_ias_position( + "C", "O", 3.0, distance_unit="bohr" + ) == direct_boundary + assert ar.estimate_ias_position( + "C", "O", 3.0, mode="boundary", distance_unit="bohr" + ) == direct_boundary + assert ar.estimate_ias_position( + "C", "O", 3.0, mode="minimum", distance_unit="bohr" + ) == direct_minimum + assert _result(direct_boundary).requested_mode == "boundary" + assert _result(direct_minimum).requested_mode == "minimum" + + +def test_result_is_frozen_slotted_and_carries_complete_provenance() -> None: + result = _result( + ar.estimate_proatomic_boundary("C", "O", 3.0, distance_unit="bohr") + ) + assert isinstance(result, ar.IASPositionResult) + assert not hasattr(result, "__dict__") + with pytest.raises(FrozenInstanceError): + result.status = "low_density_gap" + + assert result.atom_a == "C" + assert result.atom_b == "O" + assert result.distance == 3.0 + assert result.distance_unit == "bohr" + assert result.density_unit == "electron/bohr^3" + assert result.coordinate_orientation == "from_atom_a_toward_atom_b" + assert result.dataset_id == _DATASET_ID + assert result.interpolation_contract == _INTERPOLATION_CONTRACT + assert result.pairwise_contract == _PAIRWISE_CONTRACT + assert result.cutoff_density == ar.PROATOMIC_TAIL_CUTOFF + assert result.position_from_a is not None + assert result.position_from_b is not None + assert result.fraction_from_a is not None + assert result.rho_a is not None + assert result.rho_b is not None + assert result.rho_sum is not None + assert result.position_from_a + result.position_from_b == pytest.approx(3.0) + assert result.fraction_from_a == pytest.approx(result.position_from_a / 3.0) + assert result.rho_sum == pytest.approx(result.rho_a + result.rho_b) + assert result.rho_a == pytest.approx( + _profile("C")(result.position_from_a, radius_unit="bohr") + ) + assert result.rho_b == pytest.approx( + _profile("O")(result.position_from_b, radius_unit="bohr") + ) + + +def test_every_packaged_profile_has_one_analytical_cutoff_radius() -> None: + seen = 0 + for element in ar.iter_elements(): + if element.z > 103: + break + profile = _profile(element.symbol) + assert all( + right < left + for left, right in zip(profile.densities, profile.densities[1:]) + ), element.symbol + assert profile.densities[0] > ar.PROATOMIC_TAIL_CUTOFF + crossing_indices = [ + index + for index in range(1, len(profile.densities)) + if ( + profile.densities[index - 1] > ar.PROATOMIC_TAIL_CUTOFF + and profile.densities[index] <= ar.PROATOMIC_TAIL_CUTOFF + ) + ] + assert len(crossing_indices) == 1, element.symbol + + prepared = proatoms._prepared_pairwise_profile(profile) + expected = _independent_cutoff_radius(profile) + assert math.isfinite(prepared.cutoff_radius_bohr), element.symbol + assert 0.0 < prepared.cutoff_radius_bohr < 20.0, element.symbol + assert prepared.cutoff_radius_bohr == pytest.approx( + expected, + rel=2.0e-14, + ), element.symbol + reproduced = profile( + prepared.cutoff_radius_bohr, + radius_unit="bohr", + ) + assert reproduced == pytest.approx( + ar.PROATOMIC_TAIL_CUTOFF, + rel=5.0e-14, + ), element.symbol + seen += 1 + assert seen == 103 + + +def test_pairwise_profile_preparation_is_lazy_cached_and_shared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile = _profile("O") + proatoms._get_prepared_pairwise_profile_cached.cache_clear() + real_prepare = proatoms._prepare_pairwise_profile + calls = 0 + + def counting_prepare( + candidate: ar.ProatomicDensityProfile, + ) -> proatoms._PreparedPairwiseProfile: + nonlocal calls + calls += 1 + return real_prepare(candidate) + + monkeypatch.setattr(proatoms, "_prepare_pairwise_profile", counting_prepare) + profile(1.0, radius_unit="bohr") + ar.get_proatomic_density_set_info() + assert calls == 0 + + first = proatoms._prepared_pairwise_profile(profile) + second = proatoms._prepared_pairwise_profile(profile) + assert first is second + assert calls == 1 + cache_info = proatoms._get_prepared_pairwise_profile_cached.cache_info() + assert cache_info.misses == 1 + assert cache_info.hits == 1 + + +def test_cutoff_preparation_accepts_an_exact_cutoff_knot() -> None: + profile = _synthetic_profile( + (1.0, 0.1, 0.01, 0.0001, 0.00001, 0.000001), + radii=(0.1, 1.0, 4.0, 10.0, 15.0, 20.0), + set_id="synthetic_exact_cutoff", + ) + prepared = proatoms._prepare_pairwise_profile(profile) + assert prepared.cutoff_radius_bohr == profile.radii[3] + + +@pytest.mark.parametrize( + ("densities", "set_id", "message"), + [ + ( + (1.0, 0.1, 0.1, 0.001, 0.0001, 0.00001), + "synthetic_not_strict", + "strictly decreasing", + ), + ( + (0.0001, 0.00009, 0.00008, 0.00007, 0.00006, 0.00005), + "synthetic_first_below", + "first proatomic density", + ), + ( + (1.0, 0.1, 0.01, 0.001, 0.0002, 0.00011), + "synthetic_never_below", + "does not fall below", + ), + ( + (1.0, 0.1, 0.01, 0.001, 0.0002, 0.00001), + "synthetic_after_public_limit", + "before the public limit", + ), + ], +) +def test_cutoff_preparation_rejects_invalid_profile_contracts( + densities: tuple[float, ...], + set_id: str, + message: str, +) -> None: + profile = _synthetic_profile(densities, set_id=set_id) + with pytest.raises(DatasetError, match=message): + proatoms._prepare_pairwise_profile(profile) + + +@pytest.mark.parametrize("symbol", ["H", "Li"]) +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_homonuclear_pairs_return_exact_midpoint( + symbol: str, + mode: str, +) -> None: + distance = 5.0 + result = _result( + ar.estimate_ias_position( + symbol, + symbol, + distance, + mode=mode, + distance_unit="bohr", + ) + ) + assert result.method == "homonuclear_midpoint" + assert result.position_from_a == distance / 2.0 + assert result.position_from_b == distance / 2.0 + assert result.fraction_from_a == 0.5 + assert result.rho_a == result.rho_b + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_homonuclear_cutoff_gap_keeps_midpoint(mode: str) -> None: + distance = 10.0 + result = _result( + ar.estimate_ias_position( + "H", + "H", + distance, + mode=mode, + distance_unit="bohr", + ) + ) + assert result.status == "low_density_gap" + assert result.cutoff_regime == "gap" + assert result.method == "homonuclear_midpoint" + assert result.position_from_a == 5.0 + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "distance"), + [ + ("H", "O", 2.0), + ("H", "Lr", 2.0), + ("Fe", "La", 5.0), + ("La", "U", 6.0), + ], +) +def test_overlapping_unlike_boundary_balances_components( + atom_a: str, + atom_b: str, + distance: float, +) -> None: + result = _result( + ar.estimate_proatomic_boundary( + atom_a, + atom_b, + distance, + distance_unit="bohr", + ) + ) + assert result.method == "equal_proatom_density" + assert result.status == "ok" + assert result.cutoff_regime == "overlap" + assert result.position_from_a is not None + assert 0.0 < result.position_from_a < distance + assert result.rho_a == pytest.approx(result.rho_b, rel=2.0e-9) + + +def test_boundary_cutoff_gap_uses_geometric_contour_midpoint() -> None: + distance = 10.0 + result = _result( + ar.estimate_proatomic_boundary("H", "O", distance, distance_unit="bohr") + ) + expected = ( + distance + result.cutoff_radius_a - result.cutoff_radius_b + ) / 2.0 + assert result.method == "cutoff_gap_midpoint" + assert result.status == "low_density_gap" + assert result.cutoff_regime == "gap" + assert result.contour_separation > 0.0 + assert result.position_from_a == expected + + +def test_boundary_branches_are_continuous_at_cutoff_contact() -> None: + cutoff_a = proatoms._prepared_pairwise_profile( + _profile("H") + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile("O") + ).cutoff_radius_bohr + contact_distance = cutoff_a + cutoff_b + overlap = _result( + ar.estimate_proatomic_boundary( + "H", + "O", + contact_distance - 1.0e-7, + distance_unit="bohr", + ) + ) + contact = _result( + ar.estimate_proatomic_boundary( + "H", "O", contact_distance, distance_unit="bohr" + ) + ) + gap = _result( + ar.estimate_proatomic_boundary( + "H", + "O", + contact_distance + 1.0e-7, + distance_unit="bohr", + ) + ) + assert overlap.method == "equal_proatom_density" + assert overlap.cutoff_regime == "overlap" + assert contact.method == "equal_proatom_density" + assert contact.cutoff_regime == "contact" + assert gap.method == "cutoff_gap_midpoint" + assert gap.cutoff_regime == "gap" + assert contact.position_from_a == pytest.approx(cutoff_a, abs=1.0e-9) + assert overlap.position_from_a == pytest.approx(cutoff_a, abs=2.0e-7) + assert gap.position_from_a == pytest.approx(cutoff_a, abs=2.0e-7) + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_exact_cutoff_contact_is_distance_unit_invariant(mode: str) -> None: + cutoff_a = proatoms._prepared_pairwise_profile( + _profile("F") + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile("Ne") + ).cutoff_radius_bohr + distance_bohr = cutoff_a + cutoff_b + distance_angstrom = distance_bohr * ar.BOHR_TO_ANGSTROM + + native = _result( + ar.estimate_ias_position( + "F", "Ne", distance_bohr, mode=mode, distance_unit="bohr" + ) + ) + converted = _result( + ar.estimate_ias_position( + "F", "Ne", distance_angstrom, mode=mode, distance_unit="angstrom" + ) + ) + metadata_distance_angstrom = ( + native.cutoff_radius_a * ar.BOHR_TO_ANGSTROM + + native.cutoff_radius_b * ar.BOHR_TO_ANGSTROM + ) + reconstructed = _result( + ar.estimate_ias_position( + "F", + "Ne", + metadata_distance_angstrom, + mode=mode, + distance_unit="angstrom", + ) + ) + + assert ( + native.cutoff_regime + == converted.cutoff_regime + == reconstructed.cutoff_regime + == "contact" + ) + assert native.method == converted.method == reconstructed.method + assert native.status == converted.status == reconstructed.status + assert converted.contour_separation == 0.0 + if native.position_from_a is None: + assert converted.position_from_a is None + else: + assert converted.position_from_a == pytest.approx( + native.position_from_a * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + + +@pytest.mark.parametrize(("atom_a", "atom_b"), [("H", "O"), ("O", "H")]) +@pytest.mark.parametrize("distance_unit", ["bohr", "angstrom"]) +def test_minimum_rejects_near_contact_cutoff_endpoint( + atom_a: str, + atom_b: str, + distance_unit: str, +) -> None: + cutoff_a = proatoms._prepared_pairwise_profile( + _profile("H") + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile("O") + ).cutoff_radius_bohr + contact = cutoff_a + cutoff_b + distance_bohr = math.nextafter(math.nextafter(contact, 0.0), 0.0) + distance = ( + distance_bohr + if distance_unit == "bohr" + else distance_bohr * ar.BOHR_TO_ANGSTROM + ) + + result = _result( + ar.estimate_promolecular_density_minimum( + atom_a, + atom_b, + distance, + distance_unit=distance_unit, + ) + ) + + assert result.cutoff_regime == "overlap" + assert result.method == "none" + assert result.status == "no_resolved_interior_minimum" + assert result.position_from_a is None + assert result.position_from_b is None + assert result.alternative_position_from_a is None + assert result.alternative_position_from_b is None + + +def test_minimum_contact_neighbors_and_representable_interiors() -> None: + cutoff_a = proatoms._prepared_pairwise_profile( + _profile("H") + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile("He") + ).cutoff_radius_bohr + contact = cutoff_a + cutoff_b + distances = [contact] + distance = contact + for _ in range(5): + distance = math.nextafter(distance, 0.0) + distances.append(distance) + + interior_counts: list[int] = [] + for distance in (distances[0], *distances[2:]): + overlap_left = max(0.0, distance - cutoff_b) + overlap_right = min(distance, cutoff_a) + position = math.nextafter(overlap_left, overlap_right) + count = 0 + while position < overlap_right: + count += 1 + position = math.nextafter(position, overlap_right) + interior_counts.append(count) + + result = _result( + ar.estimate_promolecular_density_minimum( + "H", "He", distance, distance_unit="bohr" + ) + ) + _assert_minimum_coordinates_strictly_inside_overlap(result) + + assert interior_counts == [0, 1, 2, 3, 4] + formerly_clamped = _result( + ar.estimate_promolecular_density_minimum( + "H", "He", distances[4], distance_unit="bohr" + ) + ) + assert formerly_clamped.method == "none" + assert formerly_clamped.status == "no_resolved_interior_minimum" + + exact = _result( + ar.estimate_promolecular_density_minimum( + "H", "He", contact, distance_unit="bohr" + ) + ) + above = _result( + ar.estimate_promolecular_density_minimum( + "H", + "He", + math.nextafter(contact, math.inf), + distance_unit="bohr", + ) + ) + assert exact.method == above.method == "none" + assert exact.status == above.status == "low_density_gap" + assert exact.cutoff_regime == "contact" + assert above.cutoff_regime == "gap" + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "steps_below_contact"), + [("H", "Se", 2), ("Kr", "He", 4)], +) +@pytest.mark.parametrize("distance_unit", ["bohr", "angstrom"]) +def test_near_contact_minimum_survives_no_public_endpoint_rounding( + atom_a: str, + atom_b: str, + steps_below_contact: int, + distance_unit: str, +) -> None: + cutoff_a = proatoms._prepared_pairwise_profile( + _profile(atom_a) + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile(atom_b) + ).cutoff_radius_bohr + distance_bohr = cutoff_a + cutoff_b + for _ in range(steps_below_contact): + distance_bohr = math.nextafter(distance_bohr, 0.0) + distance = ( + distance_bohr + if distance_unit == "bohr" + else distance_bohr * ar.BOHR_TO_ANGSTROM + ) + + for requested_a, requested_b in ((atom_a, atom_b), (atom_b, atom_a)): + result = _result( + ar.estimate_promolecular_density_minimum( + requested_a, + requested_b, + distance, + distance_unit=distance_unit, + ) + ) + assert result.method == "none" + assert result.status == "no_resolved_interior_minimum" + assert result.position_from_a is None + assert result.position_from_b is None + assert result.alternative_position_from_a is None + assert result.alternative_position_from_b is None + + +def test_raw_angstrom_near_contact_cannot_expose_a_cutoff_endpoint() -> None: + atom_a = "H" + atom_b = "Fe" + cutoff_a = proatoms._prepared_pairwise_profile( + _profile(atom_a) + ).cutoff_radius_bohr + cutoff_b = proatoms._prepared_pairwise_profile( + _profile(atom_b) + ).cutoff_radius_bohr + distance = (cutoff_a + cutoff_b) * ar.BOHR_TO_ANGSTROM + distance = math.nextafter(math.nextafter(distance, 0.0), 0.0) + + for requested_a, requested_b in ((atom_a, atom_b), (atom_b, atom_a)): + result = _result( + ar.estimate_promolecular_density_minimum( + requested_a, + requested_b, + distance, + distance_unit="angstrom", + ) + ) + assert result.method == "none" + assert result.status == "no_resolved_interior_minimum" + assert result.position_from_a is None + assert result.position_from_b is None + assert result.alternative_position_from_a is None + assert result.alternative_position_from_b is None + + +def test_boundary_reports_one_atom_domination_orientation_safely() -> None: + forward = _result( + ar.estimate_proatomic_boundary("H", "Lr", 0.1, distance_unit="bohr") + ) + reverse = _result( + ar.estimate_proatomic_boundary("Lr", "H", 0.1, distance_unit="bohr") + ) + assert forward.method == reverse.method == "none" + assert forward.status == reverse.status == "one_atom_dominates" + assert forward.position_from_a is reverse.position_from_a is None + assert forward.dominant_atom == reverse.dominant_atom == "Lr" + assert forward.dominant_atom_role == "atom_b" + assert reverse.dominant_atom_role == "atom_a" + _assert_reversal(forward, reverse, 0.1) + + +def test_ordinary_minimum_is_resolved_inside_significant_overlap() -> None: + distance = 7.0 + result = _result( + ar.estimate_promolecular_density_minimum( + "H", "O", distance, distance_unit="bohr" + ) + ) + left = max(0.0, distance - result.cutoff_radius_b) + right = min(distance, result.cutoff_radius_a) + assert result.method == "promolecular_density_minimum" + assert result.status == "ok" + assert result.position_from_a is not None + assert left < result.position_from_a < right + assert result.position_from_a not in {left, right, 0.0, distance} + assert result.search_resolution == ar.IAS_MINIMUM_RESOLUTION_BOHR + assert result.search_converged is True + assert result.search_passes == 2 + + +def test_production_coalescing_uses_position_connected_components() -> None: + candidates = tuple( + proatoms._MinimumCandidate(position, density) + for position, density in ( + (0.100, 1.0), + (0.109, 1.0001), + (0.118, 1.00005), + ) + ) + + resolved = proatoms._coalesce_minimum_candidates(candidates) + + assert resolved == (proatoms._MinimumCandidate(0.100, 1.0),) + + +def test_packaged_y_np_bridge_candidates_form_one_resolved_valley() -> None: + distance = 10.212596531891476 + profile_a = proatoms._prepared_pairwise_profile(_profile("Y")) + profile_b = proatoms._prepared_pairwise_profile(_profile("Np")) + overlap_left = max( + 0.0, + distance - profile_b.cutoff_radius_bohr, + ) + overlap_right = min(distance, profile_a.cutoff_radius_bohr) + equal_position, _ = proatoms._equal_contribution_position( + profile_a, + profile_b, + distance, + ) + fallback = proatoms._minimum_grid_pass( + profile_a, + profile_b, + distance, + overlap_left, + overlap_right, + 0.005, + equal_position, + ) + result = _result( + ar.estimate_promolecular_density_minimum( + "Y", + "Np", + distance, + distance_unit="bohr", + ) + ) + reverse = _result( + ar.estimate_promolecular_density_minimum( + "Np", + "Y", + distance, + distance_unit="bohr", + ) + ) + + assert tuple( + candidate.position_bohr for candidate in fallback.candidates + ) == pytest.approx((5.037953988322846, 5.030526030430109)) + assert result.method == "promolecular_density_minimum" + assert result.status == "ok" + assert result.position_from_a == pytest.approx( + 5.0379539928790065, + abs=1.0e-10, + ) + assert result.alternative_position_from_a is None + assert result.alternative_position_from_b is None + assert result.alternative_rho_sum is None + assert result.relative_depth_gap is None + assert result.ambiguous is False + assert result.search_passes == 3 + assert result.search_converged is True + assert reverse.status == "ok" + assert reverse.alternative_position_from_a is None + _assert_reversal(result, reverse, distance) + + +@pytest.mark.parametrize( + ("atom_b", "distance_bohr", "midpoint_bohr", "density"), + [ + ( + "Mg", + 10.427738551450130, + 4.128012047240235, + 0.00020000000000000052, + ), + ( + "K", + 11.554061800270143, + 4.128012047240236, + 0.00020000000000000036, + ), + ], +) +@pytest.mark.parametrize("distance_unit", ["bohr", "angstrom"]) +def test_adjacent_grid_coordinates_preserve_near_contact_midpoint_valley( + atom_b: str, + distance_bohr: float, + midpoint_bohr: float, + density: float, + distance_unit: str, +) -> None: + distance_factor = ( + 1.0 if distance_unit == "bohr" else ar.BOHR_TO_ANGSTROM + ) + distance = distance_bohr * distance_factor + forward = _result( + ar.estimate_promolecular_density_minimum( + "H", + atom_b, + distance, + distance_unit=distance_unit, + ) + ) + reverse = _result( + ar.estimate_promolecular_density_minimum( + atom_b, + "H", + distance, + distance_unit=distance_unit, + ) + ) + + for result in (forward, reverse): + assert result.method == "promolecular_density_minimum" + assert result.status == "ok" + assert result.rho_sum == pytest.approx(density, rel=2.0e-15) + assert result.alternative_position_from_a is None + _assert_minimum_coordinates_strictly_inside_overlap(result) + assert forward.position_from_a == midpoint_bohr * distance_factor + assert reverse.position_from_a == distance - forward.position_from_a + _assert_reversal(forward, reverse, distance) + + converted = _result( + ar.estimate_promolecular_density_minimum( + "H", + atom_b, + distance, + distance_unit=distance_unit, + density_unit="electron/angstrom^3", + ) + ) + assert converted.method == forward.method + assert converted.status == forward.status + assert converted.position_from_a == forward.position_from_a + assert converted.rho_sum == pytest.approx( + density / ar.BOHR_TO_ANGSTROM**3, + rel=2.0e-15, + ) + + +def test_required_grid_passes_retain_coarse_only_competitor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + overlap_left = 0.0 + overlap_right = 1.07 + narrow_center = overlap_right * 26.0 / 54.0 + half_width = 0.002 + + def synthetic_objective( + _profile_a: object, + _profile_b: object, + _distance_bohr: float, + position_bohr: float, + ) -> float: + broad = 1.0 + (position_bohr - 0.2) ** 2 + offset = abs(position_bohr - narrow_center) + if offset >= half_width: + return broad + shape = (1.0 - (offset / half_width) ** 2) ** 2 + depth = 1.0 + (narrow_center - 0.2) ** 2 - 1.00005 + return broad - depth * shape + + monkeypatch.setattr(proatoms, "_objective", synthetic_objective) + coarse = proatoms._minimum_grid_pass( + None, + None, + overlap_right, + overlap_left, + overlap_right, + 0.02, + None, + ) + fine = proatoms._minimum_grid_pass( + None, + None, + overlap_right, + overlap_left, + overlap_right, + 0.01, + None, + ) + + assert len(coarse.candidates) == 2 + assert len(fine.candidates) == 1 + assert proatoms._selections_compatible(coarse.selected, fine.selected) + combined = proatoms._combine_confirmed_minimum_candidates(coarse, fine) + assert len(combined) == 2 + assert combined[1].position_bohr == narrow_center + + fallback = proatoms._MinimumPass(0.005, fine.candidates) + combined_with_fallback = proatoms._combine_confirmed_minimum_candidates( + coarse, + fine, + fallback, + ) + assert len(combined_with_fallback) == 2 + + pass_by_spacing = { + 0.02: coarse, + 0.01: fine, + } + + def controlled_pass( + _profile_a: object, + _profile_b: object, + _distance_bohr: float, + _overlap_left: float, + _overlap_right: float, + max_spacing_bohr: float, + _equal_position_bohr: float | None, + ) -> proatoms._MinimumPass: + return pass_by_spacing[max_spacing_bohr] + + monkeypatch.setattr(proatoms, "_minimum_grid_pass", controlled_pass) + profile_a = proatoms._prepared_pairwise_profile(_profile("H")) + profile_b = proatoms._prepared_pairwise_profile(_profile("O")) + native = proatoms._native_minimum_estimate( + profile_a, + profile_b, + overlap_right, + ) + assert native.status == "ambiguous_competing_minima" + assert native.ambiguous is True + assert native.alternative_position_bohr == narrow_center + assert native.relative_depth_gap == pytest.approx(5.0e-5) + + +def test_fallback_reconciles_candidates_from_every_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + coarse_only = proatoms._MinimumCandidate(0.5151851851851852, 1.00005) + fine_primary = proatoms._MinimumCandidate(0.8, 1.0) + coarse = proatoms._MinimumPass(0.02, (coarse_only,)) + fine = proatoms._MinimumPass(0.01, (fine_primary,)) + fallback = proatoms._MinimumPass(0.005, (fine_primary,)) + pass_by_spacing = { + 0.02: coarse, + 0.01: fine, + 0.005: fallback, + } + + def controlled_pass( + _profile_a: object, + _profile_b: object, + _distance_bohr: float, + _overlap_left: float, + _overlap_right: float, + max_spacing_bohr: float, + _equal_position_bohr: float | None, + ) -> proatoms._MinimumPass: + return pass_by_spacing[max_spacing_bohr] + + def synthetic_objective( + _profile_a: object, + _profile_b: object, + _distance_bohr: float, + position_bohr: float, + ) -> float: + return 1.0 + (position_bohr - 0.8) ** 2 + + def synthetic_components( + _profile_a: object, + _profile_b: object, + _distance_bohr: float, + position_bohr: float, + ) -> tuple[float, float, float]: + density = synthetic_objective(None, None, 1.07, position_bohr) + return density / 2.0, density / 2.0, density + + monkeypatch.setattr(proatoms, "_minimum_grid_pass", controlled_pass) + monkeypatch.setattr(proatoms, "_objective", synthetic_objective) + monkeypatch.setattr(proatoms, "_component_values", synthetic_components) + monkeypatch.setattr( + proatoms, + "_equal_contribution_position", + lambda *_args: (None, None), + ) + + profile_a = proatoms._prepared_pairwise_profile(_profile("H")) + profile_b = proatoms._prepared_pairwise_profile(_profile("O")) + native = proatoms._native_minimum_estimate(profile_a, profile_b, 1.07) + + assert native.status == "ambiguous_competing_minima" + assert native.position_bohr == fine_primary.position_bohr + assert native.alternative_position_bohr == coarse_only.position_bohr + assert native.relative_depth_gap == pytest.approx(5.0e-5) + assert native.search_passes == 3 + assert native.search_converged is True + + +def test_minimum_empty_overlap_returns_typed_gap_without_coordinate() -> None: + result = _result( + ar.estimate_promolecular_density_minimum( + "H", "O", 10.0, distance_unit="bohr" + ) + ) + assert result.requested_mode == "minimum" + assert result.method == "none" + assert result.status == "low_density_gap" + assert result.cutoff_regime == "gap" + assert result.position_from_a is None + assert result.rho_sum is None + assert result.search_passes == 0 + + +def test_no_resolved_minimum_does_not_fall_back_to_boundary_mode() -> None: + atom_a, atom_b, distance = _NO_RESOLVED_CASE + minimum = _result( + ar.estimate_promolecular_density_minimum( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + boundary = _result( + ar.estimate_proatomic_boundary( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + assert minimum.requested_mode == "minimum" + assert minimum.method == "none" + assert minimum.status == "no_resolved_interior_minimum" + assert minimum.position_from_a is None + assert minimum.rho_sum is None + assert boundary.requested_mode == "boundary" + assert boundary.status != minimum.status + + +def test_boundary_dominated_minimum_retains_interior_diagnostic() -> None: + atom_a, atom_b, distance = _BOUNDARY_DOMINATED_CASE + result = _result( + ar.estimate_promolecular_density_minimum( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + assert result.requested_mode == "minimum" + assert result.method == "promolecular_density_minimum" + assert result.status == "boundary_dominated" + assert result.position_from_a is not None + assert 0.0 < result.position_from_a < distance + assert result.rho_sum is not None + assert result.search_passes == 3 + _assert_minimum_coordinates_strictly_inside_overlap(result) + + profile_a = _profile(atom_a) + profile_b = _profile(atom_b) + boundary_density = min( + _pair_density_sum(profile_a, profile_b, distance, 0.0), + _pair_density_sum(profile_a, profile_b, distance, distance), + ) + assert boundary_density < result.rho_sum + + +def test_competing_minima_are_limited_to_one_alternative() -> None: + atom_a, atom_b, distance = _AMBIGUOUS_CASE + result = _result( + ar.estimate_promolecular_density_minimum( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + assert result.method == "promolecular_density_minimum" + assert result.status == "ambiguous_competing_minima" + assert result.ambiguous is True + assert result.alternative_position_from_a is not None + assert result.alternative_position_from_b is not None + assert result.alternative_rho_sum is not None + assert result.relative_depth_gap is not None + assert result.relative_depth_gap <= 1.0e-4 + assert abs( + result.alternative_position_from_a - result.position_from_a + ) >= ar.IAS_MINIMUM_RESOLUTION_BOHR + assert result.alternative_position_from_a + ( + result.alternative_position_from_b + ) == pytest.approx(distance) + assert result.search_passes == 3 + assert result.search_resolution == 0.005 + assert result.search_converged is True + _assert_minimum_coordinates_strictly_inside_overlap(result) + + +def test_unstable_search_returns_finest_supported_candidate() -> None: + atom_a, atom_b, distance = _UNSTABLE_CASE + result = _result( + ar.estimate_promolecular_density_minimum( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + assert result.method == "promolecular_density_minimum" + assert result.status == "search_unstable" + assert result.position_from_a is not None + assert result.search_passes == 3 + assert result.search_resolution == 0.005 + assert result.search_converged is False + _assert_minimum_coordinates_strictly_inside_overlap(result) + + +@pytest.mark.parametrize("case", [_AMBIGUOUS_CASE, _UNSTABLE_CASE]) +def test_minimum_primary_and_alternative_coordinates_reverse( + case: tuple[str, str, float], +) -> None: + atom_a, atom_b, distance = case + forward = _result( + ar.estimate_promolecular_density_minimum( + atom_a, atom_b, distance, distance_unit="bohr" + ) + ) + reverse = _result( + ar.estimate_promolecular_density_minimum( + atom_b, atom_a, distance, distance_unit="bohr" + ) + ) + assert forward.alternative_position_from_a is not None + _assert_minimum_coordinates_strictly_inside_overlap(forward) + _assert_minimum_coordinates_strictly_inside_overlap(reverse) + _assert_reversal(forward, reverse, distance) + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_ordinary_pair_reversal_swaps_components_and_coordinates(mode: str) -> None: + distance = 3.0 + forward = _result( + ar.estimate_ias_position( + "C", "O", distance, mode=mode, distance_unit="bohr" + ) + ) + reverse = _result( + ar.estimate_ias_position( + "O", "C", distance, mode=mode, distance_unit="bohr" + ) + ) + _assert_reversal(forward, reverse, distance) + + +@pytest.mark.parametrize( + ("mode", "atom_a", "atom_b", "distance_bohr"), + [ + ("boundary", "C", "O", 3.0), + ("minimum", *_AMBIGUOUS_CASE), + ], +) +def test_distance_units_preserve_physical_geometry( + mode: str, + atom_a: str, + atom_b: str, + distance_bohr: float, +) -> None: + distance_angstrom = distance_bohr * ar.BOHR_TO_ANGSTROM + native = _result( + ar.estimate_ias_position( + atom_a, + atom_b, + distance_bohr, + mode=mode, + distance_unit="bohr", + ) + ) + converted = _result( + ar.estimate_ias_position( + atom_a, + atom_b, + distance_angstrom, + mode=mode, + distance_unit="angstrom", + ) + ) + assert converted.distance == distance_angstrom + assert converted.method == native.method + assert converted.status == native.status + assert converted.cutoff_regime == native.cutoff_regime + assert converted.position_from_a == pytest.approx( + native.position_from_a * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + assert converted.position_from_b == pytest.approx( + native.position_from_b * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + assert converted.cutoff_radius_a == pytest.approx( + native.cutoff_radius_a * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + assert converted.cutoff_radius_b == pytest.approx( + native.cutoff_radius_b * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + assert converted.contour_separation == pytest.approx( + native.contour_separation * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + assert converted.alternative_position_from_a == ( + None + if native.alternative_position_from_a is None + else pytest.approx( + native.alternative_position_from_a * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + ) + assert converted.alternative_position_from_b == ( + None + if native.alternative_position_from_b is None + else pytest.approx( + native.alternative_position_from_b * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + ) + assert converted.search_resolution == ( + None + if native.search_resolution is None + else pytest.approx( + native.search_resolution * ar.BOHR_TO_ANGSTROM, + rel=2.0e-15, + ) + ) + assert converted.rho_sum == pytest.approx(native.rho_sum, rel=2.0e-15) + + +@pytest.mark.parametrize( + ("mode", "atom_a", "atom_b", "distance"), + [ + ("boundary", "C", "O", 3.0), + ("minimum", *_AMBIGUOUS_CASE), + ], +) +def test_density_unit_conversion_cannot_change_scientific_decisions( + mode: str, + atom_a: str, + atom_b: str, + distance: float, +) -> None: + native = _result( + ar.estimate_ias_position( + atom_a, + atom_b, + distance, + mode=mode, + distance_unit="bohr", + ) + ) + converted = _result( + ar.estimate_ias_position( + atom_a, + atom_b, + distance, + mode=mode, + distance_unit="bohr", + density_unit="electron/angstrom^3", + ) + ) + unchanged = ( + "requested_mode", + "method", + "status", + "position_from_a", + "position_from_b", + "fraction_from_a", + "cutoff_radius_a", + "cutoff_radius_b", + "contour_separation", + "cutoff_regime", + "alternative_position_from_a", + "alternative_position_from_b", + "relative_depth_gap", + "ambiguous", + "search_resolution", + "search_converged", + "search_passes", + ) + for field_name in unchanged: + assert getattr(converted, field_name) == getattr(native, field_name) + + factor = 1.0 / ar.BOHR_TO_ANGSTROM**3 + for field_name in ( + "rho_a", + "rho_b", + "rho_sum", + "cutoff_density", + "alternative_rho_sum", + ): + native_value = getattr(native, field_name) + converted_value = getattr(converted, field_name) + if native_value is None: + assert converted_value is None + else: + assert converted_value == pytest.approx( + native_value * factor, + rel=2.0e-15, + ) + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_deuterium_and_tritium_use_the_hydrogen_profile(mode: str) -> None: + hydrogen = ar.estimate_ias_position( + "H", "O", 2.0, mode=mode, distance_unit="bohr" + ) + assert ar.estimate_ias_position( + "D", "O", 2.0, mode=mode, distance_unit="bohr" + ) == hydrogen + assert ar.estimate_ias_position( + "T", "O", 2.0, mode=mode, distance_unit="bohr" + ) == hydrogen + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +def test_missing_profiles_return_none(mode: str) -> None: + assert ( + ar.estimate_ias_position( + "Rf", "O", 2.0, mode=mode, distance_unit="bohr" + ) + is None + ) + assert ( + ar.estimate_ias_position( + "not-an-element", "O", 2.0, mode=mode, distance_unit="bohr" + ) + is None + ) + + +@pytest.mark.parametrize( + "distance", + [0.0, -1.0, math.nan, math.inf, -math.inf, 20.0001, True, None, "bad"], +) +def test_invalid_distances_raise_value_error(distance: object) -> None: + with pytest.raises(ValueError, match="distance"): + ar.estimate_ias_position("H", "O", distance, distance_unit="bohr") + + +def test_distance_endpoint_and_arbitrarily_short_positive_values_are_valid() -> None: + endpoint = _result( + ar.estimate_ias_position("H", "H", 20.0, distance_unit="bohr") + ) + short = _result( + ar.estimate_ias_position("H", "H", 1.0e-12, distance_unit="bohr") + ) + assert endpoint.position_from_a == 10.0 + assert short.position_from_a == 0.5e-12 + + +@pytest.mark.parametrize("mode", ["boundary", "minimum"]) +@pytest.mark.parametrize("distance_unit", ["bohr", "angstrom"]) +def test_smallest_subnormal_homonuclear_midpoint_rounding_is_explicit( + mode: str, + distance_unit: str, +) -> None: + distance = math.ulp(0.0) + result = _result( + ar.estimate_ias_position( + "H", "H", distance, mode=mode, distance_unit=distance_unit + ) + ) + + assert result.method == "homonuclear_midpoint" + assert result.position_from_a == distance / 2.0 == 0.0 + assert result.position_from_b == distance - result.position_from_a + assert result.fraction_from_a == result.position_from_a / distance == 0.0 + + +@pytest.mark.parametrize(("atom_a", "atom_b"), [("H", "O"), ("O", "H")]) +def test_extreme_unlike_distance_cannot_return_a_nucleus_as_minimum( + atom_a: str, + atom_b: str, +) -> None: + result = _result( + ar.estimate_promolecular_density_minimum( + atom_a, + atom_b, + 2.0e-323, + distance_unit="bohr", + ) + ) + + assert result.method == "none" + assert result.status == "no_resolved_interior_minimum" + assert result.position_from_a is None + assert result.position_from_b is None + + +def test_invalid_mode_and_units_raise_value_error() -> None: + with pytest.raises(ValueError, match="mode"): + ar.estimate_ias_position("H", "O", 2.0, mode="automatic") + with pytest.raises(ValueError, match="distance unit"): + ar.estimate_proatomic_boundary("H", "O", 2.0, distance_unit="meter") + with pytest.raises(ValueError, match="density unit"): + ar.estimate_promolecular_density_minimum( + "H", "O", 2.0, density_unit="electron/cm^3" + ) + + +def test_missing_dataset_raises_dataset_error() -> None: + with pytest.raises(DatasetError, match="unknown dataset id"): + ar.estimate_proatomic_boundary("H", "O", 2.0, set_id="missing") + + +@pytest.mark.parametrize( + ("atom_a", "atom_b"), + [("Xx", "O"), ("Xx", "invalid")], +) +def test_pairwise_validates_dataset_before_missing_atoms( + atom_a: str, + atom_b: str, +) -> None: + with pytest.raises(DatasetError, match="unknown dataset id"): + ar.estimate_proatomic_boundary( + atom_a, + atom_b, + 1.0, + set_id="missing", + ) diff --git a/tests/proatoms/test_ias_reference.py b/tests/proatoms/test_ias_reference.py new file mode 100644 index 0000000..8c971b0 --- /dev/null +++ b/tests/proatoms/test_ias_reference.py @@ -0,0 +1,770 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import math + +import pytest + +import atomref as ar + + +_REFERENCE_GRID_SPACING_BOHR = 0.001 +_RESOLVED_VALLEY_SEPARATION_BOHR = 0.01 +_COMPETING_RELATIVE_DEPTH = 1.0e-4 + + +@dataclass(frozen=True, slots=True) +class _ReferenceCandidate: + """One valley found by the independent dense reference.""" + + position_bohr: float + density: float + + +@dataclass(frozen=True, slots=True) +class _ReferenceSearch: + """Independent cutoff-bounded reference result in native units.""" + + overlap_left_bohr: float + overlap_right_bohr: float + max_grid_spacing_bohr: float + cutoff_radius_a_bohr: float + cutoff_radius_b_bohr: float + raw_candidates: tuple[_ReferenceCandidate, ...] + resolved_candidates: tuple[_ReferenceCandidate, ...] + boundary_density: float + status: str + + @property + def selected(self) -> _ReferenceCandidate | None: + """Return the lowest resolved valley, if present.""" + + if not self.resolved_candidates: + return None + return self.resolved_candidates[0] + + @property + def alternative(self) -> _ReferenceCandidate | None: + """Return the second-lowest separated valley, if present.""" + + if len(self.resolved_candidates) < 2: + return None + return self.resolved_candidates[1] + + +def _profile(symbol: str) -> ar.ProatomicDensityProfile: + profile = ar.get_proatomic_density_profile(symbol) + assert profile is not None + return profile + + +def _independent_cutoff_radius(profile: ar.ProatomicDensityProfile) -> float: + """Invert the public profile knots without a production cutoff helper.""" + + cutoff = ar.PROATOMIC_TAIL_CUTOFF + right = next( + index + for index, density in enumerate(profile.densities) + if density <= cutoff + ) + assert right > 0 + if profile.densities[right] == cutoff: + return profile.radii[right] + + left = right - 1 + log_density_left = math.log(profile.densities[left]) + log_density_right = math.log(profile.densities[right]) + log_radius_left = math.log(profile.radii[left]) + log_radius_right = math.log(profile.radii[right]) + fraction = (math.log(cutoff) - log_density_left) / ( + log_density_right - log_density_left + ) + return math.exp( + log_radius_left + fraction * (log_radius_right - log_radius_left) + ) + + +def _objective( + profile_a: ar.ProatomicDensityProfile, + profile_b: ar.ProatomicDensityProfile, + distance_bohr: float, + position_bohr: float, +) -> float: + """Evaluate the Stage 3 profiles directly in their native units.""" + + return profile_a._evaluate_bohr( + position_bohr + ) + profile_b._evaluate_bohr(distance_bohr - position_bohr) + + +def _refine_by_fixed_subdivision( + function: Callable[[float], float], + left: float, + center: float, + right: float, +) -> _ReferenceCandidate: + """Refine a bracket by repeated nine-point subdivision, not golden search.""" + + center_value = function(center) + best = _ReferenceCandidate(center, center_value) + for _ in range(18): + if math.nextafter(left, right) == right: + break + width = right - left + points = tuple(left + width * index / 8 for index in range(9)) + values = tuple(function(point) for point in points) + best_index = min( + range(len(points)), + key=lambda index: (values[index], points[index]), + ) + local = _ReferenceCandidate( + points[best_index], + values[best_index], + ) + if (local.density, local.position_bohr) < ( + best.density, + best.position_bohr, + ): + best = local + + lower_index = max(0, best_index - 1) + upper_index = min(8, best_index + 1) + if lower_index == upper_index: + break + left, right = points[lower_index], points[upper_index] + return best + + +def _scan_reference_objective( + function: Callable[[float], float], + overlap_left: float, + overlap_right: float, + max_spacing_bohr: float, +) -> tuple[float, tuple[_ReferenceCandidate, ...]]: + """Find strict-interior valleys on one independent uniform reference grid.""" + + if overlap_right <= overlap_left: + return 0.0, () + width = overlap_right - overlap_left + segment_count = max(2, math.ceil(width / max_spacing_bohr)) + max_grid_spacing = width / segment_count + coordinates = [ + overlap_left + width * index / segment_count + for index in range(segment_count + 1) + ] + # An odd segment count misses the exact interval midpoint. An even count + # already contains it mathematically, so do not manufacture an adjacent-ULP + # duplicate by evaluating the equivalent expression a second way. + if segment_count % 2: + coordinates.append((overlap_left + overlap_right) / 2.0) + coordinates.sort() + + values = tuple(function(position) for position in coordinates) + candidates: list[_ReferenceCandidate] = [] + for index in range(1, len(coordinates) - 1): + if ( + values[index] <= values[index - 1] + and values[index] <= values[index + 1] + ): + candidate = _refine_by_fixed_subdivision( + function, + coordinates[index - 1], + coordinates[index], + coordinates[index + 1], + ) + if overlap_left < candidate.position_bohr < overlap_right: + candidates.append(candidate) + return max_grid_spacing, tuple(candidates) + + +def _coalesce_at_public_resolution( + candidates: tuple[_ReferenceCandidate, ...], + overlap_left: float, + overlap_right: float, +) -> tuple[_ReferenceCandidate, ...]: + """Group strict-interior candidates by position-connected components.""" + + ordered = sorted( + ( + candidate + for candidate in candidates + if overlap_left < candidate.position_bohr < overlap_right + ), + key=lambda candidate: candidate.position_bohr, + ) + groups: list[list[_ReferenceCandidate]] = [] + for candidate in ordered: + if ( + not groups + or candidate.position_bohr - groups[-1][-1].position_bohr + >= _RESOLVED_VALLEY_SEPARATION_BOHR + ): + groups.append([candidate]) + else: + groups[-1].append(candidate) + representatives = [ + min( + group, + key=lambda candidate: ( + candidate.density, + candidate.position_bohr, + ), + ) + for group in groups + ] + return tuple( + sorted( + representatives, + key=lambda candidate: ( + candidate.density, + candidate.position_bohr, + ), + ) + ) + + +def _reference_status( + candidates: tuple[_ReferenceCandidate, ...], + boundary_density: float, +) -> str: + """Classify the independently selected practical result.""" + + if not candidates: + return "no_resolved_interior_minimum" + + selected = candidates[0] + roundoff = 128.0 * math.ulp( + max(abs(boundary_density), abs(selected.density), 1.0) + ) + if boundary_density < selected.density - roundoff: + return "boundary_dominated" + + if len(candidates) > 1: + relative_gap = max( + 0.0, + (candidates[1].density - selected.density) / selected.density, + ) + if relative_gap <= _COMPETING_RELATIVE_DEPTH: + return "ambiguous_competing_minima" + return "ok" + + +def _independent_reference_search( + atom_a: str, + atom_b: str, + distance_bohr: float, + *, + max_spacing_bohr: float = _REFERENCE_GRID_SPACING_BOHR, +) -> _ReferenceSearch: + """Search only ``I_c`` using an independent uniform-grid implementation.""" + + profile_a = _profile(atom_a) + profile_b = _profile(atom_b) + cutoff_a = _independent_cutoff_radius(profile_a) + cutoff_b = _independent_cutoff_radius(profile_b) + overlap_left = max(0.0, distance_bohr - cutoff_b) + overlap_right = min(distance_bohr, cutoff_a) + + def function(position: float) -> float: + return _objective( + profile_a, + profile_b, + distance_bohr, + position, + ) + + boundary_density = min(function(0.0), function(distance_bohr)) + if overlap_right <= overlap_left: + return _ReferenceSearch( + overlap_left, + overlap_right, + 0.0, + cutoff_a, + cutoff_b, + (), + (), + boundary_density, + "no_resolved_interior_minimum", + ) + + max_grid_spacing, raw_candidates = _scan_reference_objective( + function, + overlap_left, + overlap_right, + max_spacing_bohr, + ) + resolved = _coalesce_at_public_resolution( + raw_candidates, + overlap_left, + overlap_right, + ) + return _ReferenceSearch( + overlap_left, + overlap_right, + max_grid_spacing, + cutoff_a, + cutoff_b, + raw_candidates, + resolved, + boundary_density, + _reference_status(resolved, boundary_density), + ) + + +def _assert_matches_dense_reference( + atom_a: str, + atom_b: str, + distance_bohr: float, + expected_status: str, +) -> None: + reference = _independent_reference_search( + atom_a, + atom_b, + distance_bohr, + ) + result = ar.estimate_promolecular_density_minimum( + atom_a, + atom_b, + distance_bohr, + distance_unit="bohr", + ) + + assert result is not None + assert reference.max_grid_spacing_bohr <= _REFERENCE_GRID_SPACING_BOHR + assert reference.status == expected_status + assert result.status == expected_status + assert result.method == "promolecular_density_minimum" + assert result.cutoff_radius_a == pytest.approx( + reference.cutoff_radius_a_bohr, + rel=5.0e-13, + ) + assert result.cutoff_radius_b == pytest.approx( + reference.cutoff_radius_b_bohr, + rel=5.0e-13, + ) + + selected = reference.selected + assert selected is not None + assert result.position_from_a is not None + assert result.rho_sum is not None + assert reference.overlap_left_bohr < result.position_from_a + assert result.position_from_a < reference.overlap_right_bohr + assert abs(result.position_from_a - selected.position_bohr) <= ( + ar.IAS_MINIMUM_RESOLUTION_BOHR + ) + assert abs(result.rho_sum - selected.density) / selected.density <= 1.0e-4 + + alternative = reference.alternative + if expected_status == "ambiguous_competing_minima": + assert alternative is not None + assert result.alternative_position_from_a is not None + assert result.alternative_rho_sum is not None + assert abs( + result.alternative_position_from_a - alternative.position_bohr + ) <= ar.IAS_MINIMUM_RESOLUTION_BOHR + assert abs( + result.alternative_rho_sum - alternative.density + ) / alternative.density <= 1.0e-4 + reference_gap = ( + alternative.density - selected.density + ) / selected.density + assert result.relative_depth_gap == pytest.approx( + reference_gap, + rel=2.0e-6, + abs=1.0e-14, + ) + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "distance_bohr", "status"), + [ + pytest.param("C", "O", 1.5, "ok", id="ordinary-c-o"), + pytest.param("H", "U", 3.8, "ok", id="asymmetric-h-u"), + pytest.param("At", "Bk", 4.0, "ok", id="heavy-at-bk"), + pytest.param( + "Fr", + "Li", + 10.44712292574865, + "ambiguous_competing_minima", + id="competing-fr-li", + ), + pytest.param( + "H", + "Dy", + 1.5865200103109787, + "boundary_dominated", + id="boundary-dominated-h-dy", + ), + ], +) +def test_practical_minimum_matches_independent_dense_reference( + atom_a: str, + atom_b: str, + distance_bohr: float, + status: str, +) -> None: + _assert_matches_dense_reference( + atom_a, + atom_b, + distance_bohr, + status, + ) + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "distance_bohr"), + [ + pytest.param( + "Ni", + "Te", + 1.5897045597171517, + id="archived-ni-te", + ), + pytest.param( + "Al", + "Pb", + 1.3645306063699802, + id="archived-al-pb", + ), + pytest.param( + "Pr", + "Re", + 0.7592738817632778, + id="archived-pr-re", + ), + ], +) +def test_archived_stage4a_resolved_cases_match_dense_reference( + atom_a: str, + atom_b: str, + distance_bohr: float, +) -> None: + _assert_matches_dense_reference(atom_a, atom_b, distance_bohr, "ok") + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "distance_bohr"), + [ + pytest.param("H", "U", 1.58, id="archived-h-u"), + pytest.param( + "U", + "H", + 0.05625504168898983, + id="adjacent-ulp-midpoint-regression", + ), + ], +) +def test_difficult_short_pairs_have_no_independent_interior_valley( + atom_a: str, + atom_b: str, + distance_bohr: float, +) -> None: + reference = _independent_reference_search( + atom_a, + atom_b, + distance_bohr, + ) + result = ar.estimate_promolecular_density_minimum( + atom_a, + atom_b, + distance_bohr, + distance_unit="bohr", + ) + + assert not reference.raw_candidates + assert result is not None + assert result.status == "no_resolved_interior_minimum" + assert result.method == "none" + assert result.position_from_a is None + assert result.rho_sum is None + + +@pytest.mark.parametrize( + ("atom_a", "atom_b", "distance_bohr", "near_nucleus"), + [ + pytest.param( + "C", + "Lu", + 0.2217301367970158, + True, + id="archived-c-lu", + ), + pytest.param( + "He", + "Ru", + 0.6552909074349161, + False, + id="archived-he-ru", + ), + ], +) +def test_subresolution_dense_valleys_are_intentionally_unresolved( + atom_a: str, + atom_b: str, + distance_bohr: float, + near_nucleus: bool, +) -> None: + dense = _independent_reference_search( + atom_a, + atom_b, + distance_bohr, + ) + practical_grid = _independent_reference_search( + atom_a, + atom_b, + distance_bohr, + max_spacing_bohr=ar.IAS_MINIMUM_RESOLUTION_BOHR, + ) + result = ar.estimate_promolecular_density_minimum( + atom_a, + atom_b, + distance_bohr, + distance_unit="bohr", + ) + + assert dense.selected is not None + assert not practical_grid.raw_candidates + if near_nucleus: + assert dense.selected.position_bohr < ar.IAS_MINIMUM_RESOLUTION_BOHR + assert result is not None + assert result.status == "no_resolved_interior_minimum" + assert result.method == "none" + assert result.position_from_a is None + + +def test_homonuclear_shell_microstructure_cannot_move_public_midpoint() -> None: + distance_bohr = 5.0 + reference = _independent_reference_search("Li", "Li", distance_bohr) + result = ar.estimate_promolecular_density_minimum( + "Li", + "Li", + distance_bohr, + distance_unit="bohr", + ) + + assert len(reference.raw_candidates) >= 5 + assert reference.selected is not None + assert abs(reference.selected.position_bohr - distance_bohr / 2.0) > ( + ar.IAS_MINIMUM_RESOLUTION_BOHR + ) + assert result is not None + assert result.method == "homonuclear_midpoint" + assert result.position_from_a == distance_bohr / 2.0 + assert result.rho_sum is not None + assert reference.selected.density < result.rho_sum + + +def test_heavy_pair_reference_and_public_result_reverse_together() -> None: + distance_bohr = 4.0 + reference = _independent_reference_search("At", "Bk", distance_bohr) + forward = ar.estimate_promolecular_density_minimum( + "At", + "Bk", + distance_bohr, + distance_unit="bohr", + ) + reverse = ar.estimate_promolecular_density_minimum( + "Bk", + "At", + distance_bohr, + distance_unit="bohr", + ) + + assert reference.selected is not None + assert forward is not None and reverse is not None + assert forward.position_from_a is not None + assert reverse.position_from_a is not None + assert forward.rho_sum is not None and reverse.rho_sum is not None + assert abs( + forward.position_from_a - reference.selected.position_bohr + ) <= ar.IAS_MINIMUM_RESOLUTION_BOHR + assert reverse.position_from_a == pytest.approx( + distance_bohr - forward.position_from_a, + abs=2.0e-15, + ) + assert reverse.rho_sum == pytest.approx(forward.rho_sum, rel=2.0e-15) + assert reverse.status == forward.status + assert reverse.method == forward.method + + +def test_packaged_y_np_reference_rejects_false_competitive_alternative() -> None: + distance_bohr = 10.212596531891476 + reference = _independent_reference_search("Y", "Np", distance_bohr) + result = ar.estimate_promolecular_density_minimum( + "Y", + "Np", + distance_bohr, + distance_unit="bohr", + ) + + assert result is not None + assert reference.status == result.status == "ok" + assert reference.selected is not None + assert reference.alternative is None + assert result.position_from_a == pytest.approx( + reference.selected.position_bohr, + abs=ar.IAS_MINIMUM_RESOLUTION_BOHR, + ) + assert result.alternative_position_from_a is None + assert result.alternative_rho_sum is None + assert result.ambiguous is False + + +@pytest.mark.parametrize( + ("atom_b", "distance_bohr", "midpoint_bohr"), + [ + ("Mg", 10.427738551450130, 4.128012047240235), + ("K", 11.554061800270143, 4.128012047240236), + ], +) +def test_adjacent_float_midpoint_valley_matches_independent_reference( + atom_b: str, + distance_bohr: float, + midpoint_bohr: float, +) -> None: + reference = _independent_reference_search("H", atom_b, distance_bohr) + result = ar.estimate_promolecular_density_minimum( + "H", + atom_b, + distance_bohr, + distance_unit="bohr", + ) + + assert result is not None + assert reference.status == result.status == "ok" + assert reference.selected is not None + assert reference.selected.position_bohr == midpoint_bohr + assert result.position_from_a == midpoint_bohr + assert result.rho_sum == reference.selected.density + + +def test_reference_grouping_uses_position_connected_components() -> None: + candidates = tuple( + _ReferenceCandidate(position, density) + for position, density in ( + (0.100, 1.0), + (0.109, 1.0001), + (0.118, 1.00005), + ) + ) + + resolved = _coalesce_at_public_resolution(candidates, 0.0, 1.0) + + assert resolved == (_ReferenceCandidate(0.100, 1.0),) + + +def test_reference_grouping_rejects_endpoints_but_retains_interior() -> None: + left = 0.0 + right = 1.0 + first_interior = math.nextafter(left, right) + last_interior = math.nextafter(right, left) + candidates = ( + _ReferenceCandidate(left, 0.5), + _ReferenceCandidate(first_interior, 1.0), + _ReferenceCandidate(0.5, 2.0), + _ReferenceCandidate(last_interior, 1.5), + _ReferenceCandidate(right, 0.5), + ) + + resolved = _coalesce_at_public_resolution(candidates, left, right) + + assert all(left < candidate.position_bohr < right for candidate in resolved) + assert first_interior in { + candidate.position_bohr for candidate in resolved + } + assert last_interior in { + candidate.position_bohr for candidate in resolved + } + + +@pytest.mark.parametrize( + ("center", "detected_spacing", "missed_spacing"), + [ + (1.07 * 26.0 / 54.0, 0.02, 0.01), + (1.07 * 53.0 / 107.0, 0.01, 0.02), + ], +) +def test_reference_scanner_resolves_non_nested_grid_valleys_independently( + center: float, + detected_spacing: float, + missed_spacing: float, +) -> None: + half_width = 0.002 + + def function(position: float) -> float: + broad = 1.0 + (position - 0.2) ** 2 + offset = abs(position - center) + if offset >= half_width: + return broad + shape = (1.0 - (offset / half_width) ** 2) ** 2 + depth = 1.0 + (center - 0.2) ** 2 - 1.00005 + return broad - depth * shape + + _, detected = _scan_reference_objective( + function, + 0.0, + 1.07, + detected_spacing, + ) + _, missed = _scan_reference_objective( + function, + 0.0, + 1.07, + missed_spacing, + ) + _, dense = _scan_reference_objective(function, 0.0, 1.07, 0.001) + + assert any( + abs(candidate.position_bohr - center) < half_width + for candidate in detected + ) + assert not any( + abs(candidate.position_bohr - center) < half_width + for candidate in missed + ) + assert any( + abs(candidate.position_bohr - center) < half_width + for candidate in dense + ) + + +@pytest.mark.parametrize(("atom_a", "atom_b"), [("H", "O"), ("O", "H")]) +@pytest.mark.parametrize("steps_below", [1, 2]) +def test_reference_rejects_near_contact_cutoff_endpoints( + atom_a: str, + atom_b: str, + steps_below: int, +) -> None: + cutoff_a = _independent_cutoff_radius(_profile("H")) + cutoff_b = _independent_cutoff_radius(_profile("O")) + distance = cutoff_a + cutoff_b + for _ in range(steps_below): + distance = math.nextafter(distance, 0.0) + + reference = _independent_reference_search(atom_a, atom_b, distance) + + for candidate in ( + *reference.raw_candidates, + *reference.resolved_candidates, + ): + assert ( + reference.overlap_left_bohr + < candidate.position_bohr + < reference.overlap_right_bohr + ) + if not reference.raw_candidates: + assert reference.status == "no_resolved_interior_minimum" + assert not reference.resolved_candidates + + +@pytest.mark.parametrize(("atom_a", "atom_b"), [("H", "O"), ("O", "H")]) +def test_reference_rejects_extreme_unlike_nuclear_candidate( + atom_a: str, + atom_b: str, +) -> None: + reference = _independent_reference_search(atom_a, atom_b, 2.0e-323) + + assert reference.status == "no_resolved_interior_minimum" + assert not reference.raw_candidates + assert not reference.resolved_candidates diff --git a/tests/proatoms/test_snapshot_builder.py b/tests/proatoms/test_snapshot_builder.py new file mode 100644 index 0000000..9bd6137 --- /dev/null +++ b/tests/proatoms/test_snapshot_builder.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from collections.abc import Callable +import importlib.util +import io +from pathlib import Path +import sys +import zipfile + +import pytest + +from atomref.elements import iter_elements + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = REPO_ROOT / "tools" / "build_proatomic_density_snapshot.py" + +spec = importlib.util.spec_from_file_location("snapshot_builder_tool", MODULE_PATH) +assert spec is not None and spec.loader is not None +snapshot_builder = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = snapshot_builder +spec.loader.exec_module(snapshot_builder) + + +_Metadata = dict[str, dict[str, dict[str, object]]] +_EXPECTED_Z = tuple(range(1, 104)) +_SYMBOL_BY_Z = { + element.z: element.symbol + for element in iter_elements() + if element.z in _EXPECTED_Z +} + + +def _state_id(z: int) -> str: + return f"neutral_z{z:03d}" + + +def _column_name(z: int) -> str: + return f"rho_e_bohr3__{_state_id(z)}" + + +def _synthetic_metadata( + *, + state_order: tuple[int, ...] = _EXPECTED_Z, + column_order: tuple[int, ...] = _EXPECTED_Z, +) -> _Metadata: + states: dict[str, dict[str, object]] = {} + columns: dict[str, dict[str, object]] = {} + for z in state_order: + states[_state_id(z)] = { + "symbol": _SYMBOL_BY_Z[z], + "z": z, + "charge": 0, + "electron_count": z, + "multiplicity": 1, + } + for z in column_order: + columns[_column_name(z)] = { + "state_id": _state_id(z), + "symbol": _SYMBOL_BY_Z[z], + "z": z, + "charge": 0, + "electron_count": z, + "multiplicity": 1, + } + return {"states": states, "columns": columns} + + +def _remove_neutral_state(metadata: _Metadata) -> None: + metadata["states"].pop(_state_id(8)) + + +def _duplicate_neutral_state(metadata: _Metadata) -> None: + metadata["states"]["duplicate_neutral_z008"] = dict( + metadata["states"][_state_id(8)] + ) + + +def _remove_selected_column(metadata: _Metadata) -> None: + metadata["columns"].pop(_column_name(8)) + + +def _disagree_with_state(metadata: _Metadata) -> None: + metadata["columns"][_column_name(8)]["multiplicity"] = 3 + + +def test_zip_builder_is_deterministic_and_normalizes_member_metadata() -> None: + csv_bytes = b"r_bohr,z001\n1e-6,1.0\n" + + first = snapshot_builder.build_deterministic_zip(csv_bytes) + second = snapshot_builder.build_deterministic_zip(csv_bytes) + assert first == second + + with zipfile.ZipFile(io.BytesIO(first), mode="r") as archive: + assert archive.comment == b"" + assert len(archive.infolist()) == 1 + member = archive.infolist()[0] + assert member.filename == snapshot_builder.ARCHIVE_MEMBER + assert member.date_time == (1980, 1, 1, 0, 0, 0) + assert member.compress_type == zipfile.ZIP_DEFLATED + assert member.create_system == 3 + assert member.external_attr == 0o100644 << 16 + assert member.internal_attr == 0 + assert member.extra == b"" + assert member.comment == b"" + assert archive.read(member) == csv_bytes + + +def test_neutral_selection_is_metadata_driven_and_returns_in_z_order() -> None: + odd_then_even = tuple(range(1, 104, 2)) + tuple(range(2, 104, 2)) + metadata = _synthetic_metadata( + state_order=tuple(reversed(_EXPECTED_Z)), + column_order=odd_then_even, + ) + + selected = snapshot_builder._select_neutral_columns(metadata) + + assert selected == tuple((z, _column_name(z)) for z in _EXPECTED_Z) + + +@pytest.mark.parametrize( + ("mutate", "message"), + ( + pytest.param( + _remove_neutral_state, + "neutral-state coverage must be exact Z=1..103", + id="missing-neutral-z", + ), + pytest.param( + _duplicate_neutral_state, + "multiple neutral states for Z=8", + id="duplicate-neutral-z", + ), + pytest.param( + _remove_selected_column, + "selected state 'neutral_z008' maps to 0 CSV columns", + id="missing-selected-column", + ), + pytest.param( + _disagree_with_state, + "state/column metadata disagree for 'neutral_z008': multiplicity", + id="state-column-disagreement", + ), + ), +) +def test_neutral_selection_rejects_invalid_metadata( + mutate: Callable[[_Metadata], None], + message: str, +) -> None: + metadata = _synthetic_metadata() + mutate(metadata) + + with pytest.raises(snapshot_builder.SnapshotError, match=message): + snapshot_builder._select_neutral_columns(metadata) + + +def test_pinned_source_reader_rejects_hash_mismatch(tmp_path: Path) -> None: + source = tmp_path / "profiles.csv" + source.write_bytes(b"not the pinned source") + + with pytest.raises(snapshot_builder.SnapshotError, match="SHA-256 mismatch"): + snapshot_builder._read_pinned( + source, + expected_sha256="0" * 64, + label="profiles.csv", + ) + + +def test_check_mode_rejects_output_byte_mismatch(tmp_path: Path) -> None: + output = tmp_path / "snapshot.zip" + output.write_bytes(b"stale snapshot") + + with pytest.raises(snapshot_builder.SnapshotError, match="snapshot differs"): + snapshot_builder._check_output(output, b"expected snapshot") diff --git a/tests/registry/test_registry.py b/tests/registry/test_registry.py index d497d9f..911940a 100644 --- a/tests/registry/test_registry.py +++ b/tests/registry/test_registry.py @@ -1,21 +1,152 @@ from __future__ import annotations +from dataclasses import FrozenInstanceError from importlib import resources +import io from types import MappingProxyType +import warnings +import zipfile import pytest import atomref as ar from atomref.errors import DatasetError +import atomref.registry as registry from atomref.registry import get_builtin_set +_RADIAL_ARCHIVE = 'synthetic_radial.zip' +_RADIAL_MEMBER = 'proatomic_density_neutral.csv' +_RADIAL_CSV = b'r_bohr,z001,z008\n0.0,1.0,8.0\n0.5,0.5,4.0\n' + + +def _make_zip( + entries: tuple[tuple[str, bytes], ...], + *, + external_attr: int = 0o100644 << 16, +) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile( + buffer, + mode='w', + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + for name, payload in entries: + member = zipfile.ZipInfo( + filename=name, + date_time=(1980, 1, 1, 0, 0, 0), + ) + member.compress_type = zipfile.ZIP_DEFLATED + member.create_system = 3 + member.external_attr = external_attr + member.internal_attr = 0 + member.extra = b'' + member.comment = b'' + archive.writestr( + member, + payload, + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + archive.comment = b'' + return buffer.getvalue() + + +def _mark_zip_member_encrypted(archive_bytes: bytes) -> bytes: + encrypted = bytearray(archive_bytes) + for signature, flag_offset in ((b'PK\x03\x04', 6), (b'PK\x01\x02', 8)): + start = encrypted.index(signature) + flag_offset + flags = int.from_bytes(encrypted[start : start + 2], 'little') | 0x1 + encrypted[start : start + 2] = flags.to_bytes(2, 'little') + return bytes(encrypted) + + +def _corrupt_deflated_member_data(archive_bytes: bytes) -> bytes: + corrupted = bytearray(archive_bytes) + with zipfile.ZipFile(io.BytesIO(archive_bytes), mode='r') as archive: + member = archive.infolist()[0] + header = member.header_offset + name_length = int.from_bytes(corrupted[header + 26 : header + 28], 'little') + extra_length = int.from_bytes(corrupted[header + 28 : header + 30], 'little') + data_start = header + 30 + name_length + extra_length + corrupted[data_start] = (corrupted[data_start] & 0xF8) | 0x07 + return bytes(corrupted) + + +@pytest.fixture +def install_synthetic_radial_registry(monkeypatch: pytest.MonkeyPatch): + real_registry_loader = registry._load_registry_json + + def clear_caches() -> None: + registry._load_builtin_set.cache_clear() + registry._load_radial_csv_zip.cache_clear() + registry._load_csv_columns.cache_clear() + real_registry_loader.cache_clear() + + def install(archive_bytes: bytes, *, member: str = _RADIAL_MEMBER) -> None: + raw_registry = { + 'quantities': { + 'synthetic_profile': { + 'domain': 'element', + 'units': 'arbitrary', + 'description': 'Synthetic radial profiles for loader tests.', + } + }, + 'datasets': { + 'synthetic_profile': { + 'synthetic': { + 'name': 'Synthetic radial profiles', + 'aliases': ['synthetic alias'], + 'storage': { + 'kind': 'element_radial_csv_zip', + 'filename': _RADIAL_ARCHIVE, + 'member': member, + 'radius_column': 'r_bohr', + 'density_column_pattern': 'z{z:03d}', + }, + 'coverage': { + 'n_values': 2, + 'z_min': 1, + 'z_max': 8, + 'covered_z': [1, 8], + }, + } + } + }, + } + + def read_package_data_bytes(filename: str) -> bytes: + assert filename == _RADIAL_ARCHIVE + return archive_bytes + + clear_caches() + monkeypatch.setattr(registry, '_load_registry_json', lambda: raw_registry) + monkeypatch.setattr( + registry, + '_read_package_data_bytes', + read_package_data_bytes, + ) + + clear_caches() + try: + yield install + finally: + clear_caches() + def test_packaged_data_files_exist() -> None: pkg = 'atomref.data' - assert resources.files(pkg).joinpath('periodic_table.csv').is_file() - assert resources.files(pkg).joinpath('covalent.csv').is_file() - assert resources.files(pkg).joinpath('van_der_waals.csv').is_file() - assert resources.files(pkg).joinpath('registry.json').is_file() + for filename in ( + 'periodic_table.csv', + 'covalent.csv', + 'van_der_waals.csv', + 'xh_bond_length.csv', + 'proatomic_density_neutral.zip', + 'registry.json', + ): + assert resources.files(pkg).joinpath(filename).is_file(), filename def test_registry_lists_vdw_sets_but_not_atomic_support_sets() -> None: @@ -33,9 +164,201 @@ def test_rahm_is_registered_as_atomic_radius() -> None: def test_builtin_set_loading_works() -> None: ds = get_builtin_set(ar.DatasetRef('covalent_radius', 'cordero2008')) + assert isinstance(ds, ar.ElementScalarSet) assert ds.get('C') == 0.76 +def test_aliases_resolve_before_builtin_set_caching() -> None: + canonical = get_builtin_set(ar.DatasetRef('covalent_radius', 'cordero2008')) + alias = get_builtin_set( + ar.DatasetRef('covalent_radius', 'Cordero-Alvarez covalent radii') + ) + assert alias is canonical + + +def test_synthetic_radial_set_uses_generic_registry_and_loader( + install_synthetic_radial_registry, +) -> None: + archive_bytes = _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV),)) + with zipfile.ZipFile(io.BytesIO(archive_bytes), mode='r') as archive: + assert archive.comment == b'' + assert len(archive.infolist()) == 1 + member = archive.infolist()[0] + assert member.filename == _RADIAL_MEMBER + assert member.date_time == (1980, 1, 1, 0, 0, 0) + assert member.compress_type == zipfile.ZIP_DEFLATED + assert member.create_system == 3 + assert member.external_attr == 0o100644 << 16 + assert member.internal_attr == 0 + assert member.extra == b'' + assert member.comment == b'' + install_synthetic_radial_registry(archive_bytes) + + assert ar.list_quantities() == ('synthetic_profile',) + quantity_info = ar.get_quantity_info('synthetic_profile') + assert quantity_info.domain == 'element' + assert ar.list_dataset_ids('synthetic_profile') == ('synthetic',) + assert tuple( + item.ref.set_id for item in ar.list_dataset_infos('synthetic_profile') + ) == ('synthetic',) + info = ar.get_dataset_info(ar.DatasetRef('synthetic_profile', 'synthetic alias')) + assert info.storage is not None + assert info.storage['kind'] == 'element_radial_csv_zip' + assert info.storage['member'] == _RADIAL_MEMBER + + via_alias = get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic alias')) + dataset = get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + assert isinstance(dataset, ar.ElementRadialSet) + assert via_alias is dataset + assert dataset.radii == (0.0, 0.5) + assert dataset.get('H') == (1.0, 0.5) + assert dataset.get(8) == (8.0, 4.0) + assert dataset.get('He') is None + + +def test_radial_zip_rejects_missing_declared_member( + install_synthetic_radial_registry, +) -> None: + install_synthetic_radial_registry( + _make_zip((('different.csv', _RADIAL_CSV),)) + ) + with pytest.raises(DatasetError, match='does not contain the declared member'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_accepts_member_without_file_type_bits( + install_synthetic_radial_registry, +) -> None: + archive_bytes = _make_zip( + ((_RADIAL_MEMBER, _RADIAL_CSV),), + external_attr=0o600 << 16, + ) + install_synthetic_radial_registry(archive_bytes) + dataset = get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + assert isinstance(dataset, ar.ElementRadialSet) + assert dataset.get('H') == (1.0, 0.5) + + +def test_radial_zip_rejects_unexpected_additional_member( + install_synthetic_radial_registry, +) -> None: + install_synthetic_radial_registry( + _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV), ('extra.txt', b'extra'))) + ) + with pytest.raises(DatasetError, match='must contain exactly one member'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_rejects_duplicate_member_names( + install_synthetic_radial_registry, +) -> None: + install_synthetic_radial_registry( + _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV), (_RADIAL_MEMBER, _RADIAL_CSV))) + ) + with pytest.raises(DatasetError, match='must contain exactly one member'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_rejects_directory_entry( + install_synthetic_radial_registry, +) -> None: + directory = 'proatomic_density_neutral.csv/' + install_synthetic_radial_registry( + _make_zip(((directory, b''),)), + member=directory, + ) + with pytest.raises(DatasetError, match='contains a directory entry'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_rejects_malformed_archive( + install_synthetic_radial_registry, +) -> None: + install_synthetic_radial_registry(b'not a ZIP archive') + with pytest.raises(DatasetError, match='invalid radial ZIP archive'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_wraps_corrupted_deflated_member( + install_synthetic_radial_registry, +) -> None: + archive_bytes = _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV),)) + install_synthetic_radial_registry( + _corrupt_deflated_member_data(archive_bytes) + ) + with pytest.raises(DatasetError, match='invalid radial ZIP archive'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_rejects_encrypted_member( + install_synthetic_radial_registry, +) -> None: + archive_bytes = _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV),)) + install_synthetic_radial_registry(_mark_zip_member_encrypted(archive_bytes)) + with pytest.raises(DatasetError, match='contains an encrypted member'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_zip_rejects_invalid_csv_columns( + install_synthetic_radial_registry, +) -> None: + invalid_csv = b'radius,z001,z008\n0.0,1.0,8.0\n' + install_synthetic_radial_registry( + _make_zip(((_RADIAL_MEMBER, invalid_csv),)) + ) + with pytest.raises(DatasetError, match='invalid radial CSV columns'): + get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + + +def test_radial_set_values_are_immutable( + install_synthetic_radial_registry, +) -> None: + install_synthetic_radial_registry( + _make_zip(((_RADIAL_MEMBER, _RADIAL_CSV),)) + ) + dataset = get_builtin_set(ar.DatasetRef('synthetic_profile', 'synthetic')) + assert isinstance(dataset, ar.ElementRadialSet) + assert isinstance(dataset.radii, tuple) + assert isinstance(dataset.get('H'), tuple) + with pytest.raises(FrozenInstanceError): + dataset.radii = (1.0,) + + +def test_unknown_storage_kind_raises_dataset_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ref = ar.DatasetRef('synthetic', 'unknown_storage') + info = ar.DatasetInfo( + ref=ref, + domain='element', + units=None, + name='Unknown storage', + storage=MappingProxyType({'kind': 'mystery'}), + ) + monkeypatch.setattr(registry, 'get_dataset_info', lambda requested: info) + registry._load_builtin_set.cache_clear() + with pytest.raises(DatasetError, match='unknown storage kind'): + get_builtin_set(ref) + + +def test_scalar_policy_rejects_radial_set_clearly() -> None: + ref = ar.DatasetRef('synthetic_profile', 'synthetic') + info = ar.DatasetInfo( + ref=ref, + domain='element', + units='arbitrary', + name='Synthetic radial profiles', + ) + radial = ar.ElementRadialSet( + ref=ref, + info=info, + radii=(0.0,), + profiles_by_z=(None, (1.0,)), + ) + with pytest.raises(DatasetError, match='radial payload; scalar dataset required'): + ar.ValuePolicy(base=radial) + + def test_list_quantities_and_quantity_info() -> None: quantities = ar.list_quantities() assert quantities == ( @@ -43,6 +366,7 @@ def test_list_quantities_and_quantity_info() -> None: 'van_der_waals_radius', 'atomic_radius', 'xh_bond_length', + 'proatomic_density', ) info = ar.get_quantity_info('atomic_radius') @@ -51,6 +375,10 @@ def test_list_quantities_and_quantity_info() -> None: assert info.units == 'angstrom' assert 'support' in (info.description or '') + proatomic = ar.get_quantity_info('proatomic_density') + assert proatomic.domain == 'element' + assert proatomic.units == 'electron/bohr^3' + def test_rahm_note_no_longer_claims_it_is_classified_as_vdw() -> None: info = ar.get_dataset_info(ar.DatasetRef('atomic_radius', 'rahm2016')) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 6a96b08..bcb6912 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -3,9 +3,8 @@ import atomref as ar -def test_version_is_present() -> None: - assert isinstance(ar.__version__, str) - assert ar.__version__ +def test_release_version() -> None: + assert ar.__version__ == '0.2.1' def test_basic_smoke_import_and_lookup() -> None: diff --git a/tools/README.md b/tools/README.md index 943900d..b54f614 100644 --- a/tools/README.md +++ b/tools/README.md @@ -5,26 +5,69 @@ release preparation. ## Scripts -- `check_dist.py` — verify that wheel and source-distribution artifacts contain - the key files expected by the project. -- `check_notebooks.py` — validate notebook JSON and execute notebook code cells. -- `check_registry.py` — validate curated registry metadata against packaged CSV - tables. -- `export_notebooks.py` — render the bundled notebooks into Markdown pages under - `docs/notebooks/`. +- `build_proatomic_density_snapshot.py` — verify the pinned local + `atomref-proatoms` 2.0.0 source and write or check the deterministic neutral + H–Lr consumer ZIP. This is a maintainer-only tool and performs no network + access. +- `check_dist.py` — verify wheel and source-distribution contents and optionally + test clean base, `notebooks`, and `all` installations from the built wheel. +- `check_notebooks.py` — smoke-execute each temporary notebook copy in an + isolated standard Jupyter child process, enforce startup, cell, and complete + process timeouts, and discard the resulting outputs. +- `check_registry.py` — validate curated registry metadata against every + packaged scalar and radial payload. - `gen_readme.py` — regenerate `README.md` from `docs/index.md`. - `release_check.py` — run the full release-preparation checklist, - including linting, tests, docs, builds, and artifact validation. + including linting, strict type and citation-schema checks, tests, docs, a + clean committed-source build with conventional archive modes, and artifact + validation. ## Typical commands ```bash +python -m mypy src/atomref +cffconvert --validate python tools/check_registry.py python tools/check_notebooks.py -python tools/export_notebooks.py python tools/gen_readme.py +python tools/check_dist.py dist --check-installs python tools/release_check.py ``` +## Citation metadata checks + +`cffconvert --validate` parses `CITATION.cff` and validates it against the CFF +1.2 schema. It catches malformed YAML, unsupported fields, missing required +fields, and invalid field values. The focused repository test checks facts that +the general schema cannot know, such as atomref's version, release date, +repository URL, license boundary, and provenance wording: + +```bash +pytest tests/meta/test_release_metadata.py +``` + +For a new release, update `version` and `date-released` in `CITATION.cff`, then +run both commands. `release_check.py` runs the schema validation and the complete +test suite automatically. + +## Neutral proatomic-density snapshot + +Run the snapshot builder against the immutable local reference dataset. Write +mode regenerates the packaged archive; check mode rebuilds it in memory and +requires an exact byte-for-byte match: + +```bash +python tools/build_proatomic_density_snapshot.py \ + --source-dir ../atomref-proatoms-reference-v2.0.0/upstream/data/profiles/pbe0_sfx2c_dyallv4z_h-lr_spherical_v2 \ + --write + +python tools/build_proatomic_density_snapshot.py \ + --source-dir ../atomref-proatoms-reference-v2.0.0/upstream/data/profiles/pbe0_sfx2c_dyallv4z_h-lr_spherical_v2 \ + --check +``` + +The upstream project is not an `atomref` dependency. Keep its complete source +data outside this repository and do not edit the generated ZIP by hand. + The main project README is generated from the documentation home page. To change `README.md`, edit `docs/index.md` and then run `python tools/gen_readme.py`. diff --git a/tools/build_proatomic_density_snapshot.py b/tools/build_proatomic_density_snapshot.py new file mode 100644 index 0000000..fc3f738 --- /dev/null +++ b/tools/build_proatomic_density_snapshot.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +"""Build the deterministic neutral H-Lr proatomic-density snapshot. + +This maintainer-only tool consumes the pinned local atomref-proatoms 2.0.0 +dataset. It performs no network access and keeps the complete upstream source +outside the atomref package. +""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import dataclass +import hashlib +import io +import json +import math +from pathlib import Path +import sys +from typing import Mapping +import zipfile + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from atomref.elements import iter_elements # noqa: E402 + + +SOURCE_DATASET_ID = "pbe0_sfx2c_dyallv4z_h-lr_spherical_v2" +SOURCE_DATA_VERSION = "2.0.0" +SOURCE_SCHEMA_VERSION = "atomref.proatoms.profile_dataset.v1" +PROFILES_SHA256 = "b5520ab009542d52098dd6dbb920966d8d13377a4a5004f584a7bd15cd41c299" +METADATA_SHA256 = "32c833ca69fa0f7eb9ed32841aafc638123ff872861e636156610e417fc4c514" +BASIS_ID = "dyall-v4z" +BASIS_SHA256 = "0ee543855f8b1e7fbe9868d4abb844d8e8cc8b8c2694067b2b40de014bb4be94" + +OUTPUT_PATH = REPO_ROOT / "src" / "atomref" / "data" / ( + "proatomic_density_neutral.zip" +) +ARCHIVE_MEMBER = "proatomic_density_neutral.csv" +RADIUS_COLUMN = "r_bohr" +PUBLIC_MAX_RADIUS_BOHR = 20.0 +EXPECTED_LAST_BELOW_BOHR = 19.865456344881434 +EXPECTED_BRACKET_BOHR = 20.1644204667093 +EXPECTED_RETAINED_ROWS = 1127 +EXPECTED_Z = tuple(range(1, 104)) + +# Scale-free tolerance used only when rejecting numerical upward noise. The +# pinned table is strictly decreasing, and its smallest relative decrease is +# about 4.20e-12, more than four times this tolerance. +MONOTONIC_REL_TOL = 1.0e-12 + + +class SnapshotError(RuntimeError): + """Raised when pinned input or generated output violates the contract.""" + + +@dataclass(frozen=True, slots=True) +class Snapshot: + """Generated snapshot bytes and concise validation facts.""" + + csv_bytes: bytes + archive_bytes: bytes + selected_profiles: int + retained_rows: int + last_below_bohr: float + bracket_bohr: float + + +def _sha256(data: bytes) -> str: + """Return the lowercase SHA-256 hex digest for ``data``.""" + + return hashlib.sha256(data).hexdigest() + + +def _read_pinned(path: Path, *, expected_sha256: str, label: str) -> bytes: + """Read one source file and reject any byte-level identity mismatch.""" + + try: + data = path.read_bytes() + except OSError as exc: + raise SnapshotError(f"cannot read {label}: {path}") from exc + actual = _sha256(data) + if actual != expected_sha256: + raise SnapshotError( + f"{label} SHA-256 mismatch: expected {expected_sha256}, got {actual}" + ) + return data + + +def _mapping(value: object, *, what: str) -> Mapping[str, object]: + """Require a string-keyed JSON object.""" + + if not isinstance(value, dict) or not all( + isinstance(key, str) for key in value + ): + raise SnapshotError(f"invalid metadata: {what} must be an object") + return value + + +def _integer(value: object, *, what: str) -> int: + """Require a JSON integer while rejecting booleans.""" + + if not isinstance(value, int) or isinstance(value, bool): + raise SnapshotError(f"invalid metadata: {what} must be an integer") + return value + + +def _expect(mapping: Mapping[str, object], key: str, expected: object) -> None: + """Require one exact metadata identity value.""" + + actual = mapping.get(key) + if actual != expected: + raise SnapshotError( + f"invalid metadata {key!r}: expected {expected!r}, got {actual!r}" + ) + + +def _load_metadata(data: bytes) -> Mapping[str, object]: + """Parse and validate pinned dataset and scientific identity metadata.""" + + try: + raw = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SnapshotError("metadata.json is not valid UTF-8 JSON") from exc + metadata = _mapping(raw, what="top level") + + expected_top_level = { + "schema_version": SOURCE_SCHEMA_VERSION, + "profile_data_version": SOURCE_DATA_VERSION, + "dataset_id": SOURCE_DATASET_ID, + "basis_id": BASIS_ID, + "basis_sha256": BASIS_SHA256, + "density_model": ( + "self_consistent_fractional_occupation_spherical_uks" + ), + } + for key, expected in expected_top_level.items(): + _expect(metadata, key, expected) + + method = _mapping(metadata.get("method"), what="method") + for key, expected in { + "scf_type": "UKS", + "xc": "PBE0", + "relativity": "sf-X2C-1e", + "spherical_basis": True, + "basis_id": BASIS_ID, + "basis_sha256": BASIS_SHA256, + }.items(): + _expect(method, key, expected) + + units = _mapping(metadata.get("units"), what="units") + _expect(units, "r", "bohr") + _expect(units, "rho", "electron/bohr^3") + + profile_grid = _mapping(metadata.get("profile_grid"), what="profile_grid") + for key, expected in { + "type": "log", + "r_min_bohr": 1.0e-6, + "r_max_bohr": 60.0, + "n": 1200, + }.items(): + _expect(profile_grid, key, expected) + return metadata + + +def _element_symbols_by_z() -> dict[int, str]: + """Return the H-Lr symbol mapping from atomref's element registry.""" + + symbols = { + element.z: element.symbol + for element in iter_elements() + if element.z in EXPECTED_Z + } + if tuple(sorted(symbols)) != EXPECTED_Z: + raise SnapshotError("atomref element registry does not cover exact Z=1..103") + return symbols + + +def _select_neutral_columns( + metadata: Mapping[str, object], +) -> tuple[tuple[int, str], ...]: + """Select exactly one metadata-declared neutral CSV column per H-Lr Z.""" + + states = _mapping(metadata.get("states"), what="states") + columns = _mapping(metadata.get("columns"), what="columns") + symbols_by_z = _element_symbols_by_z() + + selected_states: dict[int, tuple[str, Mapping[str, object]]] = {} + for state_id, value in states.items(): + state = _mapping(value, what=f"state {state_id!r}") + charge = _integer(state.get("charge"), what=f"state {state_id!r} charge") + if charge != 0: + continue + z = _integer(state.get("z"), what=f"state {state_id!r} z") + if z in selected_states: + other = selected_states[z][0] + raise SnapshotError( + f"multiple neutral states for Z={z}: {other!r} and {state_id!r}" + ) + selected_states[z] = (state_id, state) + + if tuple(sorted(selected_states)) != EXPECTED_Z: + actual = tuple(sorted(selected_states)) + raise SnapshotError( + f"neutral-state coverage must be exact Z=1..103, got {actual!r}" + ) + + columns_by_state: dict[str, list[tuple[str, Mapping[str, object]]]] = {} + for column_name, value in columns.items(): + column = _mapping(value, what=f"column {column_name!r}") + state_id = column.get("state_id") + if not isinstance(state_id, str) or not state_id: + raise SnapshotError( + f"invalid metadata: column {column_name!r} has no state_id" + ) + columns_by_state.setdefault(state_id, []).append((column_name, column)) + + selected_columns: list[tuple[int, str]] = [] + for z in EXPECTED_Z: + state_id, state = selected_states[z] + matches = columns_by_state.get(state_id, []) + if len(matches) != 1: + raise SnapshotError( + f"selected state {state_id!r} maps to {len(matches)} CSV columns" + ) + column_name, column = matches[0] + expected_column = f"rho_e_bohr3__{state_id}" + if column_name != expected_column: + raise SnapshotError( + f"selected state {state_id!r} uses unexpected column {column_name!r}" + ) + + for key in ("symbol", "z", "charge", "electron_count", "multiplicity"): + if state.get(key) != column.get(key): + raise SnapshotError( + f"state/column metadata disagree for {state_id!r}: {key}" + ) + if state.get("symbol") != symbols_by_z[z]: + raise SnapshotError( + f"selected symbol for Z={z} disagrees with atomref element registry" + ) + electron_count = _integer( + state.get("electron_count"), + what=f"state {state_id!r} electron_count", + ) + if electron_count != z: + raise SnapshotError(f"selected state {state_id!r} is not neutral") + selected_columns.append((z, column_name)) + return tuple(selected_columns) + + +def _parse_retained_csv( + profiles_data: bytes, + metadata: Mapping[str, object], + selected_columns: tuple[tuple[int, str], ...], +) -> tuple[bytes, float, float, int]: + """Validate source rows and return the decimal-preserving consumer CSV.""" + + try: + source_text = profiles_data.decode("utf-8") + except UnicodeDecodeError as exc: + raise SnapshotError("profiles.csv is not valid UTF-8") from exc + + source_handle = io.StringIO(source_text, newline="") + reader = csv.reader(source_handle) + try: + header = next(reader) + except StopIteration as exc: + raise SnapshotError("profiles.csv is empty") from exc + except csv.Error as exc: + raise SnapshotError("profiles.csv header is invalid") from exc + + if not header or header[0] != RADIUS_COLUMN: + raise SnapshotError(f"profiles.csv must start with {RADIUS_COLUMN!r}") + if len(header) != len(set(header)): + raise SnapshotError("profiles.csv contains duplicate columns") + + columns = _mapping(metadata.get("columns"), what="columns") + if tuple(header[1:]) != tuple(columns): + raise SnapshotError("profiles.csv header disagrees with column metadata") + header_index = {name: index for index, name in enumerate(header)} + missing = [name for _, name in selected_columns if name not in header_index] + if missing: + raise SnapshotError(f"selected CSV columns are missing: {missing!r}") + + output_rows: list[list[str]] = [] + previous_radius: float | None = None + previous_density: dict[int, float] = {} + last_below: float | None = None + bracket: float | None = None + + try: + for row_number, row in enumerate(reader, start=2): + if len(row) != len(header): + raise SnapshotError( + f"profiles.csv row {row_number} has {len(row)} cells; " + f"expected {len(header)}" + ) + radius_text = row[0] + try: + radius = float(radius_text) + except ValueError as exc: + raise SnapshotError( + f"invalid radius in profiles.csv row {row_number}" + ) from exc + if not math.isfinite(radius) or radius <= 0.0: + raise SnapshotError( + f"radius must be finite and positive in row {row_number}" + ) + if previous_radius is not None and radius <= previous_radius: + raise SnapshotError( + f"radii must strictly increase at profiles.csv row {row_number}" + ) + + consumer_row = [radius_text] + for z, column_name in selected_columns: + density_text = row[header_index[column_name]] + try: + density = float(density_text) + except ValueError as exc: + raise SnapshotError( + f"invalid density for Z={z} in row {row_number}" + ) from exc + if not math.isfinite(density) or density <= 0.0: + raise SnapshotError( + f"density must be finite and positive for Z={z} " + f"in row {row_number}" + ) + previous = previous_density.get(z) + if ( + previous is not None + and density > previous + and not math.isclose( + density, + previous, + rel_tol=MONOTONIC_REL_TOL, + abs_tol=0.0, + ) + ): + raise SnapshotError( + f"density increases beyond tolerance for Z={z} " + f"in row {row_number}" + ) + previous_density[z] = density + consumer_row.append(density_text) + + output_rows.append(consumer_row) + previous_radius = radius + if radius <= PUBLIC_MAX_RADIUS_BOHR: + last_below = radius + else: + bracket = radius + break + except csv.Error as exc: + raise SnapshotError("profiles.csv is invalid") from exc + + if last_below != EXPECTED_LAST_BELOW_BOHR: + raise SnapshotError( + f"unexpected last radius below 20 bohr: {last_below!r}" + ) + if bracket != EXPECTED_BRACKET_BOHR: + raise SnapshotError(f"unexpected radius bracket above 20 bohr: {bracket!r}") + if len(output_rows) != EXPECTED_RETAINED_ROWS: + raise SnapshotError( + f"unexpected retained row count: {len(output_rows)}; " + f"expected {EXPECTED_RETAINED_ROWS}" + ) + + output_handle = io.StringIO(newline="") + writer = csv.writer(output_handle, lineterminator="\n") + writer.writerow([RADIUS_COLUMN, *(f"z{z:03d}" for z in EXPECTED_Z)]) + writer.writerows(output_rows) + return ( + output_handle.getvalue().encode("utf-8"), + last_below, + bracket, + len(output_rows), + ) + + +def build_deterministic_zip(csv_bytes: bytes) -> bytes: + """Return the canonical single-member ZIP bytes for a consumer CSV.""" + + member = zipfile.ZipInfo( + filename=ARCHIVE_MEMBER, + date_time=(1980, 1, 1, 0, 0, 0), + ) + member.compress_type = zipfile.ZIP_DEFLATED + member.create_system = 3 + member.external_attr = 0o100644 << 16 + member.internal_attr = 0 + member.extra = b"" + member.comment = b"" + + buffer = io.BytesIO() + with zipfile.ZipFile( + buffer, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + archive.comment = b"" + archive.writestr( + member, + csv_bytes, + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + return buffer.getvalue() + + +def build_snapshot(profiles_csv: Path, metadata_json: Path) -> Snapshot: + """Build and fully validate the pinned neutral consumer snapshot.""" + + profiles_data = _read_pinned( + profiles_csv, + expected_sha256=PROFILES_SHA256, + label="profiles.csv", + ) + metadata_data = _read_pinned( + metadata_json, + expected_sha256=METADATA_SHA256, + label="metadata.json", + ) + metadata = _load_metadata(metadata_data) + selected_columns = _select_neutral_columns(metadata) + csv_bytes, last_below, bracket, retained_rows = _parse_retained_csv( + profiles_data, + metadata, + selected_columns, + ) + archive_bytes = build_deterministic_zip(csv_bytes) + return Snapshot( + csv_bytes=csv_bytes, + archive_bytes=archive_bytes, + selected_profiles=len(selected_columns), + retained_rows=retained_rows, + last_below_bohr=last_below, + bracket_bohr=bracket, + ) + + +def _resolve_source_paths(args: argparse.Namespace) -> tuple[Path, Path]: + """Resolve either a source directory or two explicit source paths.""" + + if args.source_dir is not None: + if args.profiles_csv is not None or args.metadata_json is not None: + raise SnapshotError( + "use --source-dir or explicit --profiles-csv/--metadata-json, not both" + ) + return args.source_dir / "profiles.csv", args.source_dir / "metadata.json" + if args.profiles_csv is None or args.metadata_json is None: + raise SnapshotError( + "provide --source-dir or both --profiles-csv and --metadata-json" + ) + return args.profiles_csv, args.metadata_json + + +def _write_output(path: Path, data: bytes) -> None: + """Write generated bytes, creating only the selected output directory.""" + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + except OSError as exc: + raise SnapshotError(f"cannot write output: {path}") from exc + + +def _check_output(path: Path, expected: bytes) -> None: + """Require the committed output to match regenerated bytes exactly.""" + + try: + actual = path.read_bytes() + except OSError as exc: + raise SnapshotError(f"cannot read output for check: {path}") from exc + if actual != expected: + raise SnapshotError( + f"snapshot differs: expected SHA-256 {_sha256(expected)}, " + f"got {_sha256(actual)}" + ) + + +def _parser() -> argparse.ArgumentParser: + """Create the snapshot-builder command-line parser.""" + + parser = argparse.ArgumentParser( + description=( + "Build or check the pinned neutral H-Lr proatomic-density snapshot." + ) + ) + parser.add_argument( + "--source-dir", + type=Path, + help=( + "local upstream dataset directory containing profiles.csv and " + "metadata.json" + ), + ) + parser.add_argument("--profiles-csv", type=Path, help="explicit profiles.csv path") + parser.add_argument( + "--metadata-json", + type=Path, + help="explicit metadata.json path", + ) + parser.add_argument( + "--output", + type=Path, + default=OUTPUT_PATH, + help=f"output ZIP path (default: {OUTPUT_PATH})", + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--write", action="store_true", help="write the snapshot") + mode.add_argument("--check", action="store_true", help="check exact output bytes") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the local snapshot builder in write or byte-check mode.""" + + parser = _parser() + args = parser.parse_args(argv) + try: + profiles_csv, metadata_json = _resolve_source_paths(args) + snapshot = build_snapshot(profiles_csv, metadata_json) + if args.write: + _write_output(args.output, snapshot.archive_bytes) + action = "wrote" + else: + _check_output(args.output, snapshot.archive_bytes) + action = "verified" + except SnapshotError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + print( + f"Source: atomref-proatoms {SOURCE_DATA_VERSION} / {SOURCE_DATASET_ID}" + ) + print( + f"Inputs: profiles sha256={PROFILES_SHA256}; " + f"metadata sha256={METADATA_SHA256}" + ) + print( + f"Selection: {snapshot.selected_profiles} neutral profiles, " + f"Z={EXPECTED_Z[0]}..{EXPECTED_Z[-1]}" + ) + print( + f"Rows: {snapshot.retained_rows}; 20-bohr bracket " + f"{snapshot.last_below_bohr!r} < 20 < {snapshot.bracket_bohr!r}" + ) + print( + f"Output: {action} {args.output} " + f"({len(snapshot.archive_bytes)} bytes, " + f"sha256={_sha256(snapshot.archive_bytes)})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_dist.py b/tools/check_dist.py index df70910..759d76b 100644 --- a/tools/check_dist.py +++ b/tools/check_dist.py @@ -3,46 +3,198 @@ from __future__ import annotations import argparse +from email import policy +from email.parser import BytesParser +import hashlib +import io +import os from pathlib import Path +import re +import stat +import subprocess +import sys import tarfile +import tempfile +import venv import zipfile REQUIRED_WHEEL_MEMBERS = { "atomref/data/periodic_table.csv", "atomref/data/covalent.csv", + "atomref/data/proatomic_density_neutral.zip", "atomref/data/van_der_waals.csv", + "atomref/data/xh_bond_length.csv", "atomref/data/registry.json", "atomref/py.typed", + "dist-info/METADATA", + "dist-info/licenses/COPYING", + "dist-info/licenses/LICENSE", + "dist-info/licenses/NOTICE.md", +} + +EXPECTED_SDIST_NOTEBOOKS = { + "docs/notebooks/01-quickstart.ipynb", + "docs/notebooks/02-policies-and-assessment.ipynb", + "docs/notebooks/03-custom-sets-and-discovery.ipynb", + "docs/notebooks/04-ias-method-selection-study.ipynb", + "docs/notebooks/05-proatomic-density-and-ias.ipynb", } REQUIRED_SDIST_SUFFIXES = { "src/atomref/data/periodic_table.csv", "src/atomref/data/covalent.csv", + "src/atomref/data/proatomic_density_neutral.zip", "src/atomref/data/van_der_waals.csv", + "src/atomref/data/xh_bond_length.csv", "src/atomref/data/registry.json", "src/atomref/py.typed", "README.md", "CHANGELOG.md", "DEV_PLAN.md", + "CITATION.cff", + "COPYING", "LICENSE", + "NOTICE.md", "pyproject.toml", - "notebooks/01-quickstart.ipynb", - "notebooks/02-policies-and-assessment.ipynb", - "notebooks/03-custom-sets-and-discovery.ipynb", - "docs/notebooks/01-quickstart.md", - "docs/notebooks/02-policies-and-assessment.md", - "docs/notebooks/03-custom-sets-and-discovery.md", + ".flake8", + "docs/index.md", + *EXPECTED_SDIST_NOTEBOOKS, + "docs/guide/notebooks.md", + "docs/guide/proatomic_density.md", + "docs/dev/architecture.md", + "docs/dev/data_curation.md", + "docs/dev/ias_method_selection.md", + "docs/api/index.md", + "docs/api/proatoms.md", + "docs/assets/ias-method-study/c-o-method-comparison.png", + "docs/assets/ias-method-study/cutoff-radii.png", + "docs/assets/ias-method-study/li-li-symmetry.png", + "tools/build_proatomic_density_snapshot.py", "tools/check_notebooks.py", - "tools/export_notebooks.py", + "tools/check_registry.py", + "tools/check_dist.py", "tools/gen_readme.py", "tools/release_check.py", "tools/README.md", } +FORBIDDEN_SDIST_MEMBERS = { + "docs/dev/dev_plan.md", + "tools/export_notebooks.py", +} + +EXPECTED_VERSION = "0.2.1" +EXPECTED_REGULAR_FILE_MODE = 0o644 +COMPONENT_EXTRAS = {"test", "notebooks", "docs", "dev"} +REQUIRED_EXTRAS = COMPONENT_EXTRAS | {"all"} +EXTRA_MARKER = re.compile(r"\bextra\s*==\s*(['\"])([-a-zA-Z0-9_.]+)\1") + +NOTEBOOKS_IMPORTS = """\ +import ipykernel +import matplotlib +import mkdocs +import mkdocs_jupyter +import nbclient +import nbformat +""" + +ALL_IMPORTS = f"""\ +{NOTEBOOKS_IMPORTS} +import build +import cffconvert +import flake8 +import material +import mkdocstrings +import mkdocstrings_handlers.python +import mypy +import pymdownx +import pytest +import twine +try: + import tomllib +except ModuleNotFoundError: + import tomli +""" + +EXTRA_IMPORTS = { + "notebooks": NOTEBOOKS_IMPORTS, + "all": ALL_IMPORTS, +} + +API_SMOKE = """\ +import atomref as ar + +assert ar.__version__ == "0.2.1" +assert ar.get_covalent_radius("C") == 0.76 +assert ar.get_vdw_radius("C") == 1.77 +assert ar.get_xh_bond_length("N") is not None +assert "atomic_radius" in ar.list_quantities() +assert "rahm2016" in ar.list_dataset_ids( + "atomic_radius", usage_role="support" +) +ref = ar.DatasetRef( + "proatomic_density", + "pbe0_sfx2c_dyallv4z_h-lr_neutral_v2", +) +dataset = ar.get_builtin_set(ref) +assert dataset.get("O") is not None +rho = ar.get_proatomic_density( + "O", + 0.75, + radius_unit="angstrom", + density_unit="electron/bohr^3", +) +assert rho is not None and rho > 0 +boundary = ar.estimate_proatomic_boundary("C", "O", 1.43) +assert boundary.position_from_a is not None +minimum = ar.estimate_promolecular_density_minimum("C", "O", 1.43) +assert minimum.requested_mode == "minimum" +selected = ar.estimate_ias_position("C", "O", 1.43, mode="boundary") +assert selected == boundary +""" + +ATTRIBUTION_MARKERS = { + "CC BY 4.0", + "10.5281/zenodo.21291021", + "10.5281/zenodo.21291022", + "pbe0_sfx2c_dyallv4z_h-lr_spherical_v2", +} + +PROATOMIC_SNAPSHOT_MEMBER = "proatomic_density_neutral.csv" +EXPECTED_PROATOMIC_SNAPSHOT_SHA256 = ( + "1ec0318c8bc8f6e71eb3125cf1d4387e4593d7bee8ff5ee5270fbcc32c70ec6b" +) +EXPECTED_PROATOMIC_CSV_SHA256 = ( + "8478da862233c8874e36d65bb5eb762cdb9cbcb0e0278733c0f425ae00c2dcfe" +) +REPO_ROOT = Path(__file__).resolve().parents[1] + class DistCheckError(RuntimeError): - """Raised when a built distribution is missing required members.""" + """Raised when a built distribution violates the release contract.""" + + +def _assert_regular_file_modes( + members: list[tuple[str, int]], + *, + label: str, +) -> None: + """Require conventional non-executable modes for regular payload files.""" + + unexpected = [ + (name, stat.S_IMODE(mode)) + for name, mode in members + if stat.S_IMODE(mode) != EXPECTED_REGULAR_FILE_MODE + ] + if unexpected: + details = ", ".join( + f"{name}={mode:04o}" for name, mode in unexpected + ) + raise DistCheckError( + f"{label} regular files must use mode " + f"{EXPECTED_REGULAR_FILE_MODE:04o}: {details}" + ) def _assert_members_present( @@ -69,26 +221,403 @@ def _members_matching_suffixes(actual: set[str], suffixes: set[str]) -> set[str] return matched +def _sdist_relative_members(actual: set[str], *, label: str) -> set[str]: + """Remove the generated source-distribution root from member names.""" + + roots = {name.split("/", 1)[0] for name in actual if name} + if len(roots) != 1: + raise DistCheckError( + f"{label} must contain exactly one generated root directory" + ) + root = next(iter(roots)) + prefix = f"{root}/" + return {name[len(prefix) :] for name in actual if name.startswith(prefix)} + + +def _assert_sdist_layout(actual: set[str], *, label: str) -> None: + """Reject duplicate notebooks and obsolete generated documentation paths.""" + + relative = _sdist_relative_members(actual, label=label) + obsolete = { + name + for name in relative + if name in FORBIDDEN_SDIST_MEMBERS + or ( + name.startswith("docs/notebooks/") + and name.endswith(".md") + ) + or ".ipynb_checkpoints/" in name + } + if obsolete: + joined = ", ".join(sorted(obsolete)) + raise DistCheckError(f"{label} contains obsolete members: {joined}") + + notebooks = {name for name in relative if name.endswith(".ipynb")} + if notebooks != EXPECTED_SDIST_NOTEBOOKS: + missing = sorted(EXPECTED_SDIST_NOTEBOOKS - notebooks) + unexpected = sorted(notebooks - EXPECTED_SDIST_NOTEBOOKS) + details: list[str] = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise DistCheckError( + f"{label} must contain exactly one source for each notebook " + f"({'; '.join(details)})" + ) + + +def _member_matching_suffix( + actual: set[str], + suffix: str, + *, + label: str, +) -> str: + """Return the unique archive member ending in ``suffix``.""" + + matches = sorted(name for name in actual if name.endswith(suffix)) + if len(matches) != 1: + raise DistCheckError( + f"{label} must contain exactly one member ending in {suffix!r}" + ) + return matches[0] + + +def _sdist_root_member(actual: set[str], filename: str, *, label: str) -> str: + """Return one file directly below the sdist's generated root directory.""" + + matches = sorted( + name + for name in actual + if name.count("/") == 1 and name.rsplit("/", 1)[-1] == filename + ) + if len(matches) != 1: + raise DistCheckError( + f"{label} must contain exactly one root-level {filename!r}" + ) + return matches[0] + + +def _assert_attribution(text: str, *, member: str, label: str) -> None: + """Raise when a packaged metadata file omits required data attribution.""" + + missing = sorted(marker for marker in ATTRIBUTION_MARKERS if marker not in text) + if missing: + joined = ", ".join(missing) + raise DistCheckError( + f"{label} member {member!r} is missing attribution markers: {joined}" + ) + + +def _decode_utf8(payload: bytes, *, member: str, label: str) -> str: + """Decode one distribution member as UTF-8 with a focused error.""" + + try: + return payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise DistCheckError( + f"{label} member {member!r} is not valid UTF-8" + ) from exc + + +def _assert_wheel_metadata(payload: bytes, *, member: str, label: str) -> None: + """Validate release version, empty core requirements, and extras.""" + + try: + metadata = BytesParser(policy=policy.default).parsebytes(payload) + except (TypeError, ValueError) as exc: + raise DistCheckError( + f"{label} member {member!r} is not valid package metadata" + ) from exc + + if metadata.get("Name") != "atomref": + raise DistCheckError(f"{label} has unexpected project metadata name") + if metadata.get("Version") != EXPECTED_VERSION: + raise DistCheckError( + f"{label} has unexpected version {metadata.get('Version')!r}; " + f"expected {EXPECTED_VERSION!r}" + ) + + provided_extras = set(metadata.get_all("Provides-Extra", [])) + missing_extras = REQUIRED_EXTRAS - provided_extras + if missing_extras: + joined = ", ".join(sorted(missing_extras)) + raise DistCheckError(f"{label} is missing extras: {joined}") + + requirements = metadata.get_all("Requires-Dist", []) + unconditional = [ + requirement + for requirement in requirements + if not EXTRA_MARKER.findall(requirement) + ] + if unconditional: + joined = ", ".join(unconditional) + raise DistCheckError( + f"{label} must not declare runtime requirements: {joined}" + ) + + by_extra: dict[str, set[str]] = { + extra: set() for extra in REQUIRED_EXTRAS + } + for requirement in requirements: + requirement_text = requirement.split(";", 1)[0].strip() + extras = {match[1] for match in EXTRA_MARKER.findall(requirement)} + for extra in REQUIRED_EXTRAS & extras: + by_extra[extra].add(requirement_text) + + empty_extras = sorted( + extra for extra in COMPONENT_EXTRAS if not by_extra[extra] + ) + if empty_extras: + joined = ", ".join(empty_extras) + raise DistCheckError(f"{label} has empty component extras: {joined}") + + expected_all = set().union( + *(by_extra[extra] for extra in COMPONENT_EXTRAS) + ) + if by_extra["all"] != expected_all: + missing = sorted(expected_all - by_extra["all"]) + unexpected = sorted(by_extra["all"] - expected_all) + details: list[str] = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise DistCheckError( + f"{label} all extra must equal the union of test, notebooks, " + f"docs, and dev ({'; '.join(details)})" + ) + + +def _assert_proatomic_snapshot(payload: bytes, *, member: str, label: str) -> None: + """Require the exact pinned consumer ZIP and scientific CSV fingerprints.""" + + archive_digest = hashlib.sha256(payload).hexdigest() + if archive_digest != EXPECTED_PROATOMIC_SNAPSHOT_SHA256: + raise DistCheckError( + f"{label} member {member!r} has unexpected proatomic snapshot " + f"SHA-256: {archive_digest}" + ) + + try: + with zipfile.ZipFile(io.BytesIO(payload), mode="r") as snapshot: + members = snapshot.infolist() + if len(members) != 1 or members[0].filename != PROATOMIC_SNAPSHOT_MEMBER: + raise DistCheckError( + f"{label} member {member!r} has an invalid nested snapshot" + ) + csv_payload = snapshot.read(members[0]) + except (RuntimeError, zipfile.BadZipFile, zipfile.LargeZipFile) as exc: + raise DistCheckError( + f"{label} member {member!r} is not a valid snapshot ZIP" + ) from exc + + csv_digest = hashlib.sha256(csv_payload).hexdigest() + if csv_digest != EXPECTED_PROATOMIC_CSV_SHA256: + raise DistCheckError( + f"{label} member {member!r} has unexpected inner CSV SHA-256: " + f"{csv_digest}" + ) + + def check_wheel(path: Path) -> None: """Validate the contents of one built wheel.""" with zipfile.ZipFile(path) as zf: + _assert_regular_file_modes( + [ + (member.filename, member.external_attr >> 16) + for member in zf.infolist() + if not member.is_dir() + ], + label=path.name, + ) names = set(zf.namelist()) - matched = { - member - for member in REQUIRED_WHEEL_MEMBERS - if any(name.endswith(member) for name in names) - } - _assert_members_present(matched, REQUIRED_WHEEL_MEMBERS, label=path.name) + matched = _members_matching_suffixes(names, REQUIRED_WHEEL_MEMBERS) + _assert_members_present(matched, REQUIRED_WHEEL_MEMBERS, label=path.name) + + snapshot_member = _member_matching_suffix( + names, + "atomref/data/proatomic_density_neutral.zip", + label=path.name, + ) + _assert_proatomic_snapshot( + zf.read(snapshot_member), + member=snapshot_member, + label=path.name, + ) + + metadata_member = _member_matching_suffix( + names, + "dist-info/METADATA", + label=path.name, + ) + _assert_wheel_metadata( + zf.read(metadata_member), + member=metadata_member, + label=path.name, + ) + + for suffix in ( + "atomref/data/registry.json", + "dist-info/licenses/NOTICE.md", + ): + member = _member_matching_suffix(names, suffix, label=path.name) + text = _decode_utf8(zf.read(member), member=member, label=path.name) + _assert_attribution(text, member=member, label=path.name) def check_sdist(path: Path) -> None: """Validate the contents of one built source distribution.""" with tarfile.open(path, "r:gz") as tf: - names = {member.name for member in tf.getmembers()} - matched = _members_matching_suffixes(names, REQUIRED_SDIST_SUFFIXES) - _assert_members_present(matched, REQUIRED_SDIST_SUFFIXES, label=path.name) + members = tf.getmembers() + _assert_regular_file_modes( + [ + (member.name, member.mode) + for member in members + if member.isreg() + ], + label=path.name, + ) + names = {member.name for member in members} + matched = _members_matching_suffixes(names, REQUIRED_SDIST_SUFFIXES) + _assert_members_present(matched, REQUIRED_SDIST_SUFFIXES, label=path.name) + _assert_sdist_layout(names, label=path.name) + + for filename in ("README.md", "CITATION.cff"): + root_member = _sdist_root_member(names, filename, label=path.name) + root_file = tf.extractfile(root_member) + if root_file is None: + raise DistCheckError( + f"{path.name} member {root_member!r} is not a regular file" + ) + if root_file.read() != (REPO_ROOT / filename).read_bytes(): + raise DistCheckError( + f"{path.name} member {root_member!r} does not exactly " + f"match the source {filename}" + ) + + snapshot_member = _member_matching_suffix( + names, + "src/atomref/data/proatomic_density_neutral.zip", + label=path.name, + ) + snapshot_file = tf.extractfile(snapshot_member) + if snapshot_file is None: + raise DistCheckError( + f"{path.name} member {snapshot_member!r} is not a regular file" + ) + _assert_proatomic_snapshot( + snapshot_file.read(), + member=snapshot_member, + label=path.name, + ) + + for suffix in ("src/atomref/data/registry.json", "NOTICE.md"): + member = _member_matching_suffix(names, suffix, label=path.name) + extracted = tf.extractfile(member) + if extracted is None: + raise DistCheckError( + f"{path.name} member {member!r} is not a regular file" + ) + text = _decode_utf8( + extracted.read(), + member=member, + label=path.name, + ) + _assert_attribution(text, member=member, label=path.name) + + +def _venv_python(env_dir: Path) -> Path: + """Return the Python executable created in ``env_dir``.""" + + bindir = "Scripts" if sys.platform.startswith("win") else "bin" + return env_dir / bindir / "python" + + +def _run_checked( + args: list[str], + *, + cwd: Path, + env: dict[str, str], + label: str, +) -> None: + """Run one clean-install subprocess with a focused failure message.""" + + display_args = list(args) + if "-c" in display_args: + code_index = display_args.index("-c") + 1 + if code_index < len(display_args): + display_args[code_index] = "" + print("+", " ".join(display_args)) + try: + subprocess.run(args, cwd=cwd, env=env, check=True) + except (OSError, subprocess.CalledProcessError) as exc: + raise DistCheckError(f"{label} clean-install check failed") from exc + + +def check_clean_installations(wheel: Path) -> None: + """Install base, notebooks, and all variants in clean environments.""" + + wheel = wheel.resolve() + if not wheel.is_file(): + raise DistCheckError(f"wheel does not exist: {wheel}") + + clean_env = os.environ.copy() + clean_env.pop("PYTHONHOME", None) + clean_env.pop("PYTHONPATH", None) + + with tempfile.TemporaryDirectory(prefix="atomref-artifact-installs-") as tmp: + root = Path(tmp) + outside_checkout = root / "outside-checkout" + outside_checkout.mkdir() + + for extra in (None, "notebooks", "all"): + label = "base" if extra is None else extra + env_dir = root / f"venv-{label}" + venv.EnvBuilder(with_pip=True).create(env_dir) + python = _venv_python(env_dir) + + if extra is None: + install_target = str(wheel) + else: + install_target = f"atomref[{extra}] @ {wheel.as_uri()}" + + install = [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + ] + if extra is None: + install.append("--no-deps") + install.append(install_target) + _run_checked( + install, + cwd=outside_checkout, + env=clean_env, + label=label, + ) + _run_checked( + [str(python), "-m", "pip", "check"], + cwd=outside_checkout, + env=clean_env, + label=label, + ) + + smoke = API_SMOKE + if extra is not None: + smoke = f"{EXTRA_IMPORTS[extra]}\n{smoke}" + _run_checked( + [str(python), "-c", smoke], + cwd=outside_checkout, + env=clean_env, + label=label, + ) + + print(f"Validated clean {label} installation from {wheel.name}.") def main() -> None: @@ -96,6 +625,14 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("dist_dir", type=Path, nargs="?", default=Path("dist")) + parser.add_argument( + "--check-installs", + action="store_true", + help=( + "install the built wheel as base, notebooks, and all variants in " + "separate clean virtual environments" + ), + ) args = parser.parse_args() dist_dir = args.dist_dir @@ -110,6 +647,13 @@ def main() -> None: check_wheel(wheel) for sdist in sdists: check_sdist(sdist) + if args.check_installs: + if len(wheels) != 1: + raise DistCheckError( + "clean-install checks require exactly one wheel in the " + "distribution directory" + ) + check_clean_installations(wheels[0]) if __name__ == "__main__": diff --git a/tools/check_notebooks.py b/tools/check_notebooks.py index 51d9dfa..89337ae 100644 --- a/tools/check_notebooks.py +++ b/tools/check_notebooks.py @@ -1,105 +1,279 @@ #!/usr/bin/env python3 -"""Validate notebook JSON structure and execute notebook code cells.""" +"""Smoke-execute notebooks with standard Jupyter tooling in a temporary tree.""" from __future__ import annotations -from contextlib import redirect_stdout -import io -import json +import argparse +import os from pathlib import Path +import shutil +import signal +import subprocess import sys +import tempfile +import time REPO_ROOT = Path(__file__).resolve().parents[1] SRC = REPO_ROOT / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -NOTEBOOKS = REPO_ROOT / "notebooks" +NOTEBOOK_DIR = REPO_ROOT / "docs" / "notebooks" REQUIRED_NOTEBOOKS = ( "01-quickstart.ipynb", "02-policies-and-assessment.ipynb", "03-custom-sets-and-discovery.ipynb", + "04-ias-method-selection-study.ipynb", + "05-proatomic-density-and-ias.ipynb", ) +CELL_TIMEOUT_SECONDS = 300 +KERNEL_STARTUP_TIMEOUT_SECONDS = 60 +WORKER_TIMEOUT_SECONDS = 420 +WORKER_TERMINATION_TIMEOUT_SECONDS = 10 +CHECK_TIMEOUT_SECONDS = 15 * 60 +IS_WINDOWS = os.name == "nt" class NotebookCheckError(RuntimeError): - """Raised when a notebook is malformed or fails to execute.""" + """Raised when a notebook smoke check cannot be prepared or completed.""" -def iter_notebooks() -> tuple[Path, ...]: - """Return the notebooks that are expected to ship with the project.""" +def default_notebooks() -> tuple[Path, ...]: + """Return the five notebooks shipped as documentation.""" - return tuple(NOTEBOOKS / name for name in REQUIRED_NOTEBOOKS) + return tuple(NOTEBOOK_DIR / name for name in REQUIRED_NOTEBOOKS) -def load_notebook(path: Path) -> dict[str, object]: - """Load one notebook JSON document.""" +def _resolve_notebooks(paths: list[Path]) -> tuple[Path, ...]: + """Resolve requested notebooks and reject missing or ambiguous inputs.""" - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise NotebookCheckError(f"{path.name}: expected top-level JSON object") - return data + notebooks = tuple( + path.resolve() for path in (paths if paths else list(default_notebooks())) + ) + names: set[str] = set() + for path in notebooks: + if not path.is_file(): + raise NotebookCheckError(f"missing notebook: {path}") + if path.suffix != ".ipynb": + raise NotebookCheckError(f"not a Jupyter notebook: {path}") + if path.name in names: + raise NotebookCheckError( + f"notebook names must be unique for temporary execution: {path.name}" + ) + names.add(path.name) + if not notebooks: + raise NotebookCheckError("no notebooks selected") + return notebooks -def iter_code_cells(data: dict[str, object], *, path: Path) -> tuple[str, ...]: - """Return notebook code-cell sources in order.""" +def _worker_environment(temporary_root: Path) -> dict[str, str]: + """Return an isolated environment for one notebook worker.""" - cells = data.get("cells") - if not isinstance(cells, list): - raise NotebookCheckError(f"{path.name}: missing notebook cell list") + environment = os.environ.copy() + environment.update( + { + "IPYTHONDIR": str(temporary_root / "ipython"), + "JUPYTER_CONFIG_DIR": str(temporary_root / "jupyter"), + "JUPYTER_RUNTIME_DIR": str(temporary_root / "jupyter-runtime"), + "MPLBACKEND": "Agg", + "MPLCONFIGDIR": str(temporary_root / "matplotlib"), + "PYTHONPYCACHEPREFIX": str(temporary_root / "pycache"), + } + ) + pythonpath = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = str(temporary_root.parent.parent / "src") + if pythonpath: + environment["PYTHONPATH"] += os.pathsep + pythonpath + return environment - code: list[str] = [] - for index, cell in enumerate(cells): - if not isinstance(cell, dict): - raise NotebookCheckError(f"{path.name}: cell {index} is not an object") - cell_type = cell.get("cell_type") - if cell_type != "code": - continue - source = cell.get("source", []) - if isinstance(source, str): - text = source - elif isinstance(source, list) and all(isinstance(line, str) for line in source): - text = "".join(source) - else: - raise NotebookCheckError( - f"{path.name}: cell {index} has invalid code source" + +def _worker_group_options() -> dict[str, object]: + """Return platform-specific options that isolate a worker process group.""" + + if IS_WINDOWS: + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + +def _terminate_worker(process: subprocess.Popen[bytes]) -> None: + """Force-terminate and reap one expired worker and its process group/tree.""" + + if process.poll() is not None: + return + + containment_error: BaseException | None = None + if IS_WINDOWS: + try: + result = subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=WORKER_TERMINATION_TIMEOUT_SECONDS, ) - code.append(text) - if not code: - raise NotebookCheckError(f"{path.name}: contains no code cells") - return tuple(code) - - -def execute_notebook(path: Path) -> None: - """Execute all code cells from one notebook in a shared namespace.""" - - if not path.exists(): - raise NotebookCheckError(f"missing notebook: {path}") - data = load_notebook(path) - namespace = {"__name__": "__main__"} - for index, source in enumerate(iter_code_cells(data, path=path), start=1): - if not source.strip(): - continue + if result.returncode: + containment_error = NotebookCheckError( + f"taskkill exited with status {result.returncode}" + ) + except (OSError, subprocess.TimeoutExpired) as error: + containment_error = error + if containment_error is not None: + try: + process.kill() + except ProcessLookupError: + pass + else: + # nbclient handles SIGTERM by starting kernel cleanup. Cleanup is the + # phase being contained here, so an expired worker must be killed. + # Jupyter's parent-death handling remains responsible for its + # separately sessioned kernel once this owning worker disappears. try: - code = compile(source, f"{path.name}::cell{index}", "exec") - with redirect_stdout(io.StringIO()): - exec(code, namespace, namespace) - except Exception as exc: # noqa: BLE001 + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + try: + process.wait(timeout=WORKER_TERMINATION_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + try: + process.kill() + except ProcessLookupError: + pass + try: + process.wait(timeout=WORKER_TERMINATION_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as error: raise NotebookCheckError( - f"{path.name}: execution failed in code cell {index}: {exc}" - ) from exc + f"worker process {process.pid} could not be reaped after forced " + "termination" + ) from error + if containment_error is not None: + raise NotebookCheckError( + f"could not confirm termination of Windows worker tree {process.pid}; " + "the direct worker was force-terminated" + ) from containment_error -def main() -> int: - """Validate and execute every required notebook.""" - notebooks = iter_notebooks() - for notebook in notebooks: - execute_notebook(notebook) - print(f"Validated {len(notebooks)} notebook(s).") +def _run_worker( + path: Path, + *, + working_dir: Path, + runtime_root: Path, + wall_timeout: float = WORKER_TIMEOUT_SECONDS, +) -> None: + """Run one temporary notebook in a bounded isolated child process.""" + + runtime_root.mkdir(parents=True, exist_ok=True) + # The standard CLI owns every Jupyter object and cleanup handler. The + # supervisor only waits for and, when necessary, terminates this process. + command = [ + sys.executable, + "-m", + "jupyter", + "execute", + f"--timeout={CELL_TIMEOUT_SECONDS}", + f"--startup_timeout={KERNEL_STARTUP_TIMEOUT_SECONDS}", + "--Application.log_level=INFO", + "--inplace", + str(path), + ] + process = subprocess.Popen( + command, + cwd=working_dir, + env=_worker_environment(runtime_root), + **_worker_group_options(), + ) + try: + returncode = process.wait(timeout=wall_timeout) + except subprocess.TimeoutExpired as error: + try: + _terminate_worker(process) + except NotebookCheckError as containment_error: + raise NotebookCheckError( + f"{path.name}: worker exceeded the {wall_timeout:g}-second " + "wall-clock timeout and forced containment also failed: " + f"{containment_error}" + ) from error + raise NotebookCheckError( + f"{path.name}: worker exceeded the {wall_timeout:g}-second " + "wall-clock timeout during Jupyter kernel startup, cell execution, " + "kernel cleanup, or process exit; worker containment completed" + ) from error + if returncode: + raise NotebookCheckError( + f"{path.name}: Jupyter startup, execution, or cleanup failed; " + f"worker exited with status {returncode}" + ) + + +def smoke_execute(paths: list[Path]) -> int: + """Execute selected notebooks only after copying them into a temporary tree.""" + + notebooks = _resolve_notebooks(paths) + deadline = time.monotonic() + CHECK_TIMEOUT_SECONDS + with tempfile.TemporaryDirectory(prefix="atomref-notebooks-") as tmp: + temporary_root = Path(tmp) + temporary_notebooks = temporary_root / "docs" / "notebooks" + temporary_notebooks.mkdir(parents=True) + shutil.copytree( + SRC, + temporary_root / "src", + ignore=shutil.ignore_patterns("__pycache__", "*.py[co]"), + ) + copied = [] + for source in notebooks: + destination = temporary_notebooks / source.name + shutil.copy2(source, destination) + copied.append(destination) + + for index, notebook in enumerate(copied, start=1): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise NotebookCheckError( + f"{notebook.name}: the complete notebook smoke check exceeded " + f"its {CHECK_TIMEOUT_SECONDS}-second wall-clock timeout before " + "this worker could start" + ) + print( + f"[{index}/{len(copied)}] {notebook.name}: phase=Jupyter " + "startup/execution/cleanup", + flush=True, + ) + _run_worker( + notebook, + working_dir=temporary_notebooks, + runtime_root=temporary_root / "runtime" / notebook.stem, + wall_timeout=min(WORKER_TIMEOUT_SECONDS, remaining), + ) + print( + f"[{index}/{len(copied)}] {notebook.name}: phase=process exit; " + "worker exited cleanly", + flush=True, + ) + + print( + f"Smoke-executed {len(notebooks)} notebook(s) in temporary kernels.", + flush=True, + ) return 0 +def main() -> int: + """Parse notebook paths and run the temporary Jupyter smoke check.""" + + parser = argparse.ArgumentParser( + description=( + "Smoke-execute documentation notebooks in a disposable tree without " + "changing or comparing committed outputs." + ) + ) + parser.add_argument( + "notebooks", + nargs="*", + type=Path, + help="optional notebook paths; defaults to every shipped notebook", + ) + args = parser.parse_args() + return smoke_execute(args.notebooks) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/check_registry.py b/tools/check_registry.py index 3af6025..63d9a01 100644 --- a/tools/check_registry.py +++ b/tools/check_registry.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 -"""Validate packaged registry metadata against bundled CSV tables.""" +"""Validate packaged registry metadata against bundled data payloads.""" from __future__ import annotations from collections import defaultdict from dataclasses import asdict from importlib import import_module +import math from pathlib import Path import sys from typing import Iterable @@ -16,6 +17,33 @@ sys.path.insert(0, str(SRC)) _ALLOWED_USAGE_ROLES = {"target", "support"} +_ALLOWED_STORAGE_KINDS = {"element_scalar_csv", "element_radial_csv_zip"} +_RADIAL_REQUIRED_TEXT_FIELDS = ( + "member", + "radius_column", + "density_column_pattern", + "native_coordinate_unit", + "native_density_unit", + "interpolation_contract", + "charge_scope", + "source_project", + "source_release", + "source_dataset_id", + "basis_id", + "profile_data_version", + "electronic_method", + "scf_model", + "relativity", + "data_license", + "data_license_url", + "concept_doi", + "version_doi", +) +_RADIAL_REQUIRED_SHA256_FIELDS = ( + "source_profiles_sha256", + "source_metadata_sha256", + "basis_sha256", +) def _load_atomref_module(): @@ -58,6 +86,158 @@ def _validate_alias_collisions(errors: list[str]) -> None: seen[key] = set_id +def _is_finite_number(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ) + + +def _validate_radial_storage(ref, info, errors: list[str]) -> None: + storage = info.storage + if storage is None: + return + + if storage.get("format") != "wide_csv_zip": + errors.append( + f"unsupported radial storage format for {ref!r}: " + f"{storage.get('format')!r}" + ) + + for field in _RADIAL_REQUIRED_TEXT_FIELDS: + value = storage.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append( + f"missing or invalid radial metadata {field!r} for {ref!r}" + ) + + hexadecimal = frozenset("0123456789abcdef") + for field in _RADIAL_REQUIRED_SHA256_FIELDS: + value = storage.get(field) + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in hexadecimal for character in value) + ): + errors.append(f"invalid radial SHA-256 {field!r} for {ref!r}") + + retained_rows = storage.get("retained_rows") + if ( + not isinstance(retained_rows, int) + or isinstance(retained_rows, bool) + or retained_rows <= 0 + ): + errors.append(f"invalid retained_rows for {ref!r}: {retained_rows!r}") + + public_limit = storage.get("public_max_radius_bohr") + if not _is_finite_number(public_limit) or float(public_limit) <= 0.0: + errors.append( + f"invalid public_max_radius_bohr for {ref!r}: {public_limit!r}" + ) + + bracket = storage.get("retained_bracketing_radius_bohr") + if not _is_finite_number(bracket): + errors.append( + f"invalid retained_bracketing_radius_bohr for {ref!r}: {bracket!r}" + ) + elif _is_finite_number(public_limit) and float(bracket) <= float(public_limit): + errors.append( + f"radial bracket does not exceed the public limit for {ref!r}" + ) + + tolerance = storage.get("monotonicity_relative_tolerance") + if not _is_finite_number(tolerance) or float(tolerance) < 0.0: + errors.append( + f"invalid monotonicity_relative_tolerance for {ref!r}: " + f"{tolerance!r}" + ) + + if storage.get("native_density_unit") != info.units: + errors.append( + f"radial native density unit mismatch for {ref!r}: " + f"{storage.get('native_density_unit')!r} != {info.units!r}" + ) + + +def _validate_radial_values(ref, info, dataset, errors: list[str]) -> None: + storage = info.storage + if storage is None: + return + + radii = dataset.radii + if not radii: + errors.append(f"radial dataset has no radii for {ref!r}") + else: + if any(not math.isfinite(radius) or radius <= 0.0 for radius in radii): + errors.append( + f"radial dataset has nonpositive or non-finite radii: {ref!r}" + ) + if any(right <= left for left, right in zip(radii, radii[1:])): + errors.append(f"radial dataset radii do not strictly increase: {ref!r}") + + retained_rows = storage.get("retained_rows") + if ( + isinstance(retained_rows, int) + and not isinstance(retained_rows, bool) + and len(radii) != retained_rows + ): + errors.append( + f"radial row-count mismatch for {ref!r}: loaded {len(radii)}, " + f"declared {retained_rows}" + ) + + public_limit = storage.get("public_max_radius_bohr") + bracket = storage.get("retained_bracketing_radius_bohr") + if _is_finite_number(public_limit) and radii: + if len(radii) < 2 or not ( + radii[-2] <= float(public_limit) < radii[-1] + ): + errors.append( + f"radial grid does not retain exactly one public-limit bracket " + f"for {ref!r}" + ) + if _is_finite_number(bracket) and radii and radii[-1] != float(bracket): + errors.append( + f"radial bracket mismatch for {ref!r}: loaded {radii[-1]!r}, " + f"declared {bracket!r}" + ) + + tolerance = storage.get("monotonicity_relative_tolerance") + usable_tolerance = ( + float(tolerance) + if _is_finite_number(tolerance) and float(tolerance) >= 0.0 + else None + ) + for z, profile in enumerate(dataset.profiles_by_z): + if z == 0 or profile is None: + continue + if len(profile) != len(radii): + errors.append( + f"radial profile length mismatch for {ref!r}, Z={z}: " + f"{len(profile)} != {len(radii)}" + ) + continue + if any(not math.isfinite(value) or value <= 0.0 for value in profile): + errors.append( + f"radial profile has nonpositive or non-finite values for " + f"{ref!r}, Z={z}" + ) + if usable_tolerance is not None and any( + current > previous + and not math.isclose( + current, + previous, + rel_tol=usable_tolerance, + abs_tol=0.0, + ) + for previous, current in zip(profile, profile[1:]) + ): + errors.append( + f"radial profile increases beyond tolerance for {ref!r}, Z={z}" + ) + + def _validate_dataset_metadata(errors: list[str]) -> None: ar = _load_atomref_module() quantities = set(ar.list_quantities()) @@ -96,39 +276,73 @@ def _validate_dataset_metadata(errors: list[str]) -> None: if info.storage is None: errors.append(f"missing storage metadata for {ref!r}") else: + kind = info.storage.get("kind") filename = info.storage.get("filename") - column = info.storage.get("column") - fmt = info.storage.get("format") + if kind not in _ALLOWED_STORAGE_KINDS: + errors.append(f"unsupported storage kind for {ref!r}: {kind!r}") if not isinstance(filename, str) or not filename: errors.append(f"invalid storage filename for {ref!r}: {filename!r}") - if not isinstance(column, str) or not column: - errors.append(f"invalid storage column for {ref!r}: {column!r}") - if fmt != "dense_by_z_csv": - errors.append(f"unsupported storage format for {ref!r}: {fmt!r}") + if kind == "element_scalar_csv": + column = info.storage.get("column") + fmt = info.storage.get("format") + if not isinstance(column, str) or not column: + errors.append(f"invalid storage column for {ref!r}: {column!r}") + if fmt != "dense_by_z_csv": + errors.append(f"unsupported storage format for {ref!r}: {fmt!r}") + elif kind == "element_radial_csv_zip": + member = info.storage.get("member") + radius_column = info.storage.get("radius_column") + density_pattern = info.storage.get("density_column_pattern") + if not isinstance(member, str) or not member: + errors.append( + f"invalid radial archive member for {ref!r}: {member!r}" + ) + if not isinstance(radius_column, str) or not radius_column: + errors.append( + f"invalid radial radius column for {ref!r}: {radius_column!r}" + ) + if not isinstance(density_pattern, str) or "{z" not in density_pattern: + errors.append( + f"invalid radial density-column pattern for {ref!r}: " + f"{density_pattern!r}" + ) + _validate_radial_storage(ref, info, errors) + values_by_z = ( + dataset.values_by_z + if isinstance(dataset, ar.ElementScalarSet) + else dataset.profiles_by_z + ) coverage = info.coverage if coverage is None: errors.append(f"missing coverage metadata for {ref!r}") - max_z = len(dataset.values_by_z) - 1 + max_z = len(values_by_z) - 1 else: max_z = ( coverage.z_max if coverage.z_max is not None - else len(dataset.values_by_z) - 1 + else len(values_by_z) - 1 ) covered_z = tuple( z - for z, value in enumerate(dataset.values_by_z) + for z, value in enumerate(values_by_z) if z > 0 and value is not None and z <= max_z ) covered_set = set(covered_z) missing_z = tuple(z for z in range(1, max_z + 1) if z not in covered_set) - has_placeholders = info.placeholder_value is not None and any( - value is not None and abs(value - info.placeholder_value) < 1e-12 - for value in dataset.values_by_z[1 : max_z + 1] + has_placeholders = ( + isinstance(dataset, ar.ElementScalarSet) + and info.placeholder_value is not None + and any( + value is not None and abs(value - info.placeholder_value) < 1e-12 + for value in dataset.values_by_z[1 : max_z + 1] + ) ) + if isinstance(dataset, ar.ElementRadialSet): + _validate_radial_values(ref, info, dataset, errors) + if coverage is not None: expected = { "n_values": len(covered_z), diff --git a/tools/export_notebooks.py b/tools/export_notebooks.py deleted file mode 100644 index aa6761d..0000000 --- a/tools/export_notebooks.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""Export bundled notebooks to Markdown pages for the docs.""" - -from __future__ import annotations - -from contextlib import redirect_stdout -import argparse -import io -import json -from pathlib import Path -import sys - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SRC = REPO_ROOT / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -NOTEBOOKS = REPO_ROOT / "notebooks" -DEFAULT_OUTPUT_DIR = REPO_ROOT / "docs" / "notebooks" -NOTEBOOK_OUTPUTS = { - "01-quickstart.ipynb": "01-quickstart.md", - "02-policies-and-assessment.ipynb": "02-policies-and-assessment.md", - "03-custom-sets-and-discovery.ipynb": "03-custom-sets-and-discovery.md", -} -HEADER = ( - "\n" - "\n\n" -) - - -class NotebookExportError(RuntimeError): - """Raised when notebook export fails.""" - - -def _load_notebook(path: Path) -> dict[str, object]: - """Load one notebook JSON document.""" - - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise NotebookExportError(f"{path.name}: expected top-level JSON object") - return data - - -def _cell_source(cell: dict[str, object], *, path: Path, index: int) -> str: - """Return normalized source text for one notebook cell.""" - - source = cell.get("source", []) - if isinstance(source, str): - return source - if isinstance(source, list) and all(isinstance(line, str) for line in source): - return "".join(source) - raise NotebookExportError(f"{path.name}: invalid source in cell {index}") - - -def _export_markdown(path: Path) -> str: - """Render one notebook as Markdown, executing code cells for output.""" - - data = _load_notebook(path) - cells = data.get("cells") - if not isinstance(cells, list): - raise NotebookExportError(f"{path.name}: missing notebook cell list") - - namespace = {"__name__": "__main__"} - parts: list[str] = [HEADER] - parts.append( - f"[Open the original notebook on GitHub]" - f"(https://github.com/DeloneCommons/atomref/blob/main/notebooks/{path.name})\n" - ) - - for index, cell in enumerate(cells, start=1): - if not isinstance(cell, dict): - raise NotebookExportError(f"{path.name}: cell {index} is not an object") - source = _cell_source(cell, path=path, index=index) - cell_type = cell.get("cell_type") - if cell_type == "markdown": - text = source.strip() - if text: - parts.append(f"{text}\n") - continue - if cell_type != "code": - continue - code_text = source.rstrip() - parts.append("```python\n") - parts.append(f"{code_text}\n") - parts.append("```\n") - if not code_text.strip(): - continue - - stdout = io.StringIO() - try: - code = compile(code_text, f"{path.name}::cell{index}", "exec") - with redirect_stdout(stdout): - exec(code, namespace, namespace) - except Exception as exc: # noqa: BLE001 - raise NotebookExportError( - f"{path.name}: execution failed in code cell {index}: {exc}" - ) from exc - - output = stdout.getvalue().rstrip() - if output: - parts.append("**Output**\n\n") - parts.append("```text\n") - parts.append(f"{output}\n") - parts.append("```\n") - - return "\n".join(part.rstrip() for part in parts if part).rstrip() + "\n" - - -def export_notebooks(output_dir: Path, *, check: bool = False) -> int: - """Export bundled notebooks or verify that exported pages are in sync.""" - - output_dir.mkdir(parents=True, exist_ok=True) - for notebook_name, output_name in NOTEBOOK_OUTPUTS.items(): - notebook_path = NOTEBOOKS / notebook_name - rendered = _export_markdown(notebook_path) - output_path = output_dir / output_name - if check: - current = output_path.read_text(encoding="utf-8").replace("\r\n", "\n") - if current != rendered: - print( - f"{output_path} is out of sync with {notebook_path.name}", - file=sys.stderr, - ) - return 1 - else: - output_path.write_text(rendered, encoding="utf-8", newline="\n") - return 0 - - -def main() -> int: - """Export notebook Markdown pages or check that they are current.""" - - parser = argparse.ArgumentParser() - parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) - parser.add_argument( - "--check", - action="store_true", - help="exit with status 1 when exported pages are out of sync", - ) - args = parser.parse_args() - return export_notebooks(args.output_dir, check=args.check) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/gen_readme.py b/tools/gen_readme.py index 8ce9259..b4de3ed 100644 --- a/tools/gen_readme.py +++ b/tools/gen_readme.py @@ -79,15 +79,15 @@ def main() -> int: ) args = parser.parse_args() - rendered = render_readme() + rendered = render_readme().encode("utf-8") if args.check: - current = args.output.read_text(encoding="utf-8") + current = args.output.read_bytes() if current != rendered: print(f"{args.output} is out of sync with docs/index.md", file=sys.stderr) return 1 return 0 - args.output.write_text(rendered, encoding="utf-8") + args.output.write_bytes(rendered) return 0 diff --git a/tools/release_check.py b/tools/release_check.py index a357a18..6df19b9 100644 --- a/tools/release_check.py +++ b/tools/release_check.py @@ -4,30 +4,70 @@ This helper is intended for local release preparation. It runs the same checks that are exercised separately in CI, then builds source and wheel artifacts, validates them, and smoke-tests the built wheel in an isolated virtual -environment. +environment for each supported user installation. """ from __future__ import annotations import argparse +import os from pathlib import Path import shutil import subprocess import sys +import tarfile import tempfile -import venv REPO_ROOT = Path(__file__).resolve().parents[1] DIST_DIR = REPO_ROOT / "dist" BUILD_DIR = REPO_ROOT / "build" +MKDOCS_ENV = {"NO_MKDOCS_2_WARNING": "true"} +NOTEBOOK_CHECK_TIMEOUT_SECONDS = 16 * 60 + + +def _run( + *args: str, + cwd: Path = REPO_ROOT, + extra_env: dict[str, str] | None = None, + timeout: float | None = None, +) -> None: + """Run one subprocess command in the selected working directory.""" + + print(f"+ [cwd={cwd}]", " ".join(args), flush=True) + environment = None + if extra_env is not None: + environment = os.environ.copy() + environment.update(extra_env) + subprocess.run( + args, + cwd=cwd, + env=environment, + check=True, + timeout=timeout, + ) + + +def _build_docs() -> None: + """Build strict docs without Material's inapplicable MkDocs 2 banner.""" + + _run("mkdocs", "build", "--strict", extra_env=MKDOCS_ENV) + +def _check_types() -> None: + """Run mypy with the same Python environment as this release check.""" -def _run(*args: str, env: dict[str, str] | None = None) -> None: - """Run one subprocess command in the repository root.""" + _run(sys.executable, "-m", "mypy", "src/atomref") - print("+", " ".join(args)) - subprocess.run(args, cwd=REPO_ROOT, check=True, env=env) + +def _check_notebooks() -> None: + """Run the internally bounded checker with a final release-gate timeout.""" + + _run( + sys.executable, + "tools/check_notebooks.py", + timeout=NOTEBOOK_CHECK_TIMEOUT_SECONDS, + ) def _fresh_build_dirs() -> None: @@ -37,37 +77,78 @@ def _fresh_build_dirs() -> None: shutil.rmtree(BUILD_DIR, ignore_errors=True) -def _smoke_test_wheel() -> None: - """Install the built wheel into a temporary virtualenv and import it.""" +def _assert_clean_worktree() -> None: + """Require artifacts to be built from the exact committed source tree.""" + + status = subprocess.run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout + if status: + raise RuntimeError( + "release artifacts must be built from a clean committed worktree" + ) - wheels = sorted(DIST_DIR.glob("*.whl")) - if not wheels: - raise RuntimeError("no wheel found in dist/") - wheel = wheels[-1] - with tempfile.TemporaryDirectory(prefix="atomref-release-check-") as tmp: - env_dir = Path(tmp) / "venv" - builder = venv.EnvBuilder(with_pip=True) - builder.create(env_dir) - bindir = "Scripts" if sys.platform.startswith("win") else "bin" - python = env_dir / bindir / "python" - _run(str(python), "-m", "pip", "install", "--no-deps", str(wheel)) +def _normalize_source_modes(source_root: Path) -> None: + """Set conventional modes in a disposable source-archive extraction.""" + + source_root.chmod(0o755) + for path in source_root.rglob("*"): + if path.is_symlink(): + continue + if path.is_dir(): + path.chmod(0o755) + elif path.is_file(): + path.chmod(0o644) + + +def _extract_source_archive( + source_archive: tarfile.TarFile, + source_root: Path, +) -> None: + """Extract a trusted Git archive with an explicit safe filter when available.""" + + if hasattr(tarfile, "data_filter"): + source_archive.extractall(source_root, filter="data") + else: # pragma: no cover - extraction filters were added after Python 3.10 + source_archive.extractall(source_root) + + +def _build_from_committed_head() -> None: + """Build artifacts from a normalized temporary extraction of ``HEAD``.""" + + _assert_clean_worktree() + with tempfile.TemporaryDirectory(prefix="atomref-release-source-") as tmp: + temporary_root = Path(tmp) + archive = temporary_root / "atomref-head.tar" + source_root = temporary_root / "source" + source_root.mkdir() + _run( + "git", + "archive", + "--format=tar", + f"--output={archive}", + "HEAD", + ) + with tarfile.open(archive, mode="r:") as source_archive: + _extract_source_archive(source_archive, source_root) + _normalize_source_modes(source_root) _run( - str(python), - "-c", - ( - "import atomref as ar; " - "assert ar.get_covalent_radius('C') == 0.76; " - "assert ar.get_vdw_radius('C') == 1.77; " - "assert 'atomic_radius' in ar.list_quantities(); " - "assert 'rahm2016' in ar.list_dataset_ids(" - "'atomic_radius', usage_role='support')" - ), + sys.executable, + "-m", + "build", + "--outdir", + str(DIST_DIR), + cwd=source_root, ) def main() -> int: - """Run lint, tests, docs, build, metadata, and wheel smoke checks.""" + """Run lint, tests, docs, build, metadata, and artifact checks.""" parser = argparse.ArgumentParser( description="Run the full release-preparation checks for the repository.", @@ -78,27 +159,31 @@ def main() -> int: help="skip the strict MkDocs build step", ) parser.add_argument( - "--skip-smoke-test", + "--skip-install-checks", action="store_true", - help="skip the temporary-virtualenv wheel import smoke test", + help="skip clean base, notebooks, and all wheel installation checks", ) args = parser.parse_args() + _assert_clean_worktree() _run("flake8", "src", "tests", "tools") + _check_types() + _run("cffconvert", "--validate") _run(sys.executable, "tools/check_registry.py") - _run(sys.executable, "tools/check_notebooks.py") - _run(sys.executable, "tools/export_notebooks.py", "--check") + _check_notebooks() _run(sys.executable, "tools/gen_readme.py", "--check") _run(sys.executable, "-m", "pytest", "-q") if not args.skip_docs: - _run("mkdocs", "build", "--strict") + _build_docs() _fresh_build_dirs() - _run(sys.executable, "-m", "build") + _build_from_committed_head() _run(sys.executable, "-m", "twine", "check", "dist/*") - _run(sys.executable, "tools/check_dist.py", "dist") - if not args.skip_smoke_test: - _smoke_test_wheel() + dist_check = [sys.executable, "tools/check_dist.py", "dist"] + if not args.skip_install_checks: + dist_check.append("--check-installs") + _run(*dist_check) + _assert_clean_worktree() return 0